diff --git a/internal/config/config.go b/internal/config/config.go index 901aec00..cd70c5ee 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1761,11 +1761,22 @@ func FileVersion(path string) (string, error) { } return "", fmt.Errorf("读取配置文件失败: %w", err) } + version, err := parseTopVersion(data) + if err != nil { + return "", fmt.Errorf("解析配置文件失败: %w", err) + } + return version, nil +} + +// parseTopVersion 从配置文本里取顶层 version。走 YAML 解析,version / "version" / 'version' +// 三种写法一视同仁——读取认得的写法,写入也必须认得,否则就会在合法的引号键配置上追加出 +// 第二个 version 键(见 WriteVersion)。 +func parseTopVersion(data []byte) (string, error) { var thin struct { Version string `yaml:"version"` } if err := yaml.Unmarshal(data, &thin); err != nil { - return "", fmt.Errorf("解析配置文件失败: %w", err) + return "", err } return strings.TrimSpace(thin.Version), nil } @@ -1774,9 +1785,22 @@ func FileVersion(path string) (string, error) { // 配置上,带引号或换行的值会把 YAML 写坏。 var versionValuePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]*$`) +// topVersionKey 判断一行是不是顶层的 version 键,三种 YAML 都认的写法都在内:version:、 +// "version":、'version':。缩进的同名键属于别的段落,不动。 +func topVersionKey(line string) bool { + for _, key := range []string{"version", `"version"`, "'version'"} { + if strings.HasPrefix(line, key) { + return strings.HasPrefix(strings.TrimSpace(line[len(key):]), ":") + } + } + return false +} + // WriteVersion 就地替换配置文件的顶层 version 字段,保留其它每一行的原样(tarball 升级 // 路径一直这么做);没有这个字段就插到文件开头。返回是否真的改了。写盘走临时文件 + 原子 -// rename——这是运维者的活配置,不能留半截。 +// rename——这是运维者的活配置,不能留半截。替换后先把新文本解析回验一遍:行级替换够不着的 +// 写法(JSON 单行、多行标量……)解析失败或读不回目标值就拒绝写、原文件不动。版本号停在旧值 +// 只是页头显示问题,写坏配置是起不来。 func WriteVersion(path, version string) (bool, error) { version = strings.TrimSpace(version) if version == "" { @@ -1796,9 +1820,7 @@ func WriteVersion(path, version string) (bool, error) { 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, ":") { + if topVersionKey(line) { lines[i] = `version: "` + version + `"` replaced = true break @@ -1811,6 +1833,9 @@ func WriteVersion(path, version string) (bool, error) { if out == string(data) { return false, nil } + if got, err := parseTopVersion([]byte(out)); err != nil || got != version { + return false, fmt.Errorf("version 的写法无法就地替换(改后配置解析失败或读回为 %q),已放弃写入,原文件未改动", got) + } tmp := path + ".tmp" if err := os.WriteFile(tmp, []byte(out), info.Mode().Perm()); err != nil { return false, err diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 3f77d2be..2ec3d311 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -425,6 +425,62 @@ func TestFileVersionAndWriteVersion(t *testing.T) { } } +// FileVersion 用 YAML 解析(引号键认得出),WriteVersion 若只认顶格裸键,合法配置就会被追加 +// 出第二个 version 键、整个文件随后 Load 失败。这条测试钉死两者口径一致,以及"改不了就拒绝写"。 +func TestWriteVersionRewritesQuotedKeysAndRefusesWhatItCannot(t *testing.T) { + dir := t.TempDir() + + // Legal spellings of the same top-level key: replaced in place, exactly one key left. + for _, original := range []string{ + "\"version\": \"v1.0.0\"\nserver:\n port: 8088\n", + "'version': v1.0.0\nserver:\n port: 8088\n", + "\"version\": v1.0.0\nserver:\n port: 8088\n", + } { + path := filepath.Join(dir, "quoted.yaml") + if err := os.WriteFile(path, []byte(original), 0o644); err != nil { + t.Fatal(err) + } + if v, err := FileVersion(path); err != nil || v != "v1.0.0" { + t.Fatalf("FileVersion(%q) = %q, %v", original, v, err) + } + changed, err := WriteVersion(path, "v1.7.22") + if err != nil || !changed { + t.Fatalf("WriteVersion(%q): changed=%v err=%v", original, changed, err) + } + got := readTestFile(t, path) + if strings.Count(got, "version") != 1 { + t.Fatalf("writing %q left %d version keys:\n%q", original, strings.Count(got, "version"), got) + } + if v, err := FileVersion(path); err != nil || v != "v1.7.22" { + t.Fatalf("after writing %q: FileVersion = %q, %v (file %q)", original, v, err, got) + } + if !strings.Contains(got, "port: 8088") { + t.Fatalf("the rest of the file must stay: %q", got) + } + } + + // Forms a line-based replacement cannot rewrite: refused with the file untouched. A + // stale version number is a display artifact; a config that no longer loads is an + // installation that no longer starts. + for _, original := range []string{ + "{\"version\": \"v1.0.0\", \"server\": {\"port\": 8088}}\n", + "version: >-\n v1.0.0\nserver:\n port: 8088\n", + "\"version\": \"v9.9.9\"\n\"version\": \"v1.0.0\"\n", + } { + path := filepath.Join(dir, "unwritable.yaml") + if err := os.WriteFile(path, []byte(original), 0o644); err != nil { + t.Fatal(err) + } + changed, err := WriteVersion(path, "v1.7.22") + if err == nil { + t.Fatalf("WriteVersion(%q) must refuse instead of writing: changed=%v file=%q", original, changed, readTestFile(t, path)) + } + if got := readTestFile(t, path); got != original { + t.Fatalf("a refused write must not touch the file:\nbefore %q\nafter %q", original, got) + } + } +} + func readTestFile(t *testing.T, path string) string { t.Helper() data, err := os.ReadFile(path) diff --git a/internal/update/update_test.go b/internal/update/update_test.go index df61d773..78c22473 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -2,6 +2,7 @@ package update import ( "context" + "cyberstrike-ai/internal/config" "os" "os/exec" "path/filepath" @@ -456,6 +457,48 @@ func TestApplySyncsTheReleaseVersionIntoTheLiveConfig(t *testing.T) { } } +// The same sync end to end, with the key spelled the way issue #4 was filed about: a legal +// quoted key must be replaced in place (not appended next to), and the rollback must still +// find a config it can read. +func TestApplySyncsTheReleaseVersionIntoAQuotedKeyConfig(t *testing.T) { + requireGit(t) + tr := newTree(t) + live := filepath.Join(tr.install, "config.yaml") + writeFile(t, live, "\"version\": \"v1.0.0\"\nserver:\n port: 8088\n") + tr.upstreamCommit(t, "release: bump version", map[string]string{ + "config.example.yaml": "version: \"v1.1.0\"\n", + "internal_service.go": "package service\n\nconst Version = \"2\"\n", + }) + + if _, err := Apply(context.Background(), opts(tr), nil); err != nil { + t.Fatalf("apply failed: %v", err) + } + got := readFile(t, live) + if strings.Count(got, "version") != 1 { + t.Fatalf("live config gained a duplicate version key:\n%q", got) + } + if v, err := config.FileVersion(live); err != nil || v != "v1.1.0" { + t.Fatalf("live config after apply reads %q (%v), want v1.1.0:\n%q", v, err, got) + } + if !strings.Contains(got, "port: 8088") { + t.Fatalf("the rest of the config must stay:\n%q", got) + } + + 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) + } + got = readFile(t, live) + if strings.Count(got, "version") != 1 { + t.Fatalf("rollback left a broken config:\n%q", got) + } + if v, err := config.FileVersion(live); err != nil || v != "v1.0.0" { + t.Fatalf("live config after rollback reads %q (%v), want v1.0.0:\n%q", v, err, got) + } +} + func TestRollbackDoesNotClobberAManuallyChangedVersion(t *testing.T) { requireGit(t) tr := newTree(t) diff --git a/upgrade.sh b/upgrade.sh index ae0e267f..48380749 100644 --- a/upgrade.sh +++ b/upgrade.sh @@ -502,30 +502,53 @@ check_tarball_install() { } update_config_version() { - # Replace config.yaml's version: ... with the specified tag. + # Replace config.yaml's top-level version with the specified tag. The line matching + # mirrors the Go writer (internal/config.WriteVersion): top-level key only, in every + # spelling YAML accepts (version / "version" / 'version'), first occurrence only - a + # nested key or a later duplicate belongs to the file's own structure and must not be + # rewritten. A value the line cannot replace safely (block scalar / multi-line scalar) + # leaves the file alone on purpose: a stale version number is a display problem, a + # config that no longer loads is an installation that no longer starts. local new_tag="$1" - python3 - "$CONFIG_FILE" "$new_tag" <") or (value == "" and nxt[:1] in (" ", "\t")): + sys.stderr.write("版本号未写入:config.yaml 的 version 是多行/块标量写法,无法就地替换(原文件未改动)\n") + refused = True + break + out.append('version: "%s"\n' % tag) + replaced = True + continue + out.append(line) -if not replaced: - # If no version field is found, insert at the beginning (near the top). - out.insert(0, f'version: "{tag}"\n') - -with open(path, "w", encoding="utf-8") as f: - f.writelines(out) +if not refused: + if not replaced: + out.insert(0, 'version: "%s"\n' % tag) + tmp = path + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + f.writelines(out) + os.replace(tmp, path) PY }