Skip to content

Commit bd0dd05

Browse files
committed
fix(updater): 增强自更新校验和回滚
1 parent 1bb3053 commit bd0dd05

4 files changed

Lines changed: 251 additions & 17 deletions

File tree

cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ func CreateRootCmd() *cobra.Command {
3333
rootCmd.AddCommand(CreateDoctorCmd())
3434
rootCmd.AddCommand(CreateCheckUpdateCmd())
3535
rootCmd.AddCommand(CreateUpdateCmd())
36+
rootCmd.AddCommand(CreateRollbackCmd())
3637
rootCmd.AddCommand(CreateVersionCmd())
3738

3839
// 全局标志

cmd/update.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,3 +117,21 @@ func CreateUpdateCmd() *cobra.Command {
117117
},
118118
}
119119
}
120+
121+
// CreateRollbackCmd 创建回滚命令。
122+
func CreateRollbackCmd() *cobra.Command {
123+
return &cobra.Command{
124+
Use: "rollback",
125+
Short: "回滚到上一个版本",
126+
Long: "使用上一次更新保留的备份二进制回滚当前程序",
127+
SilenceErrors: true,
128+
SilenceUsage: true,
129+
RunE: func(cmd *cobra.Command, args []string) error {
130+
if err := updater.Rollback(); err != nil {
131+
return err
132+
}
133+
fmt.Println("已回滚到上一个版本")
134+
return nil
135+
},
136+
}
137+
}

internal/updater/updater.go

Lines changed: 150 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"net/http"
1414
"net/url"
1515
"os"
16+
"os/exec"
1617
"path/filepath"
1718
"runtime"
1819
"strconv"
@@ -24,10 +25,11 @@ import (
2425
)
2526

2627
const (
27-
githubRepo = "https-cert/deploy"
28-
githubAPIURL = "https://api.github.com/repos/" + githubRepo + "/releases/latest"
29-
downloadTimeout = 10 * time.Minute
30-
downloadRetries = 3
28+
githubRepo = "https-cert/deploy"
29+
githubAPIURL = "https://api.github.com/repos/" + githubRepo + "/releases/latest"
30+
downloadTimeout = 10 * time.Minute
31+
downloadRetries = 3
32+
smokeTestTimeout = 15 * time.Second
3133
)
3234

3335
// 常见的 GitHub 镜像加速服务
@@ -136,13 +138,12 @@ func PerformUpdate(ctx context.Context, info *UpdateInfo) error {
136138
logger.Info("下载更新中...", "version", info.LatestVersion)
137139

138140
// 获取当前可执行文件路径
139-
execPath, err := os.Executable()
141+
execPath, err := currentExecutablePath()
140142
if err != nil {
141-
return fmt.Errorf("获取可执行文件路径失败: %w", err)
143+
return err
142144
}
143-
execPath, err = filepath.EvalSymlinks(execPath)
144-
if err != nil {
145-
return fmt.Errorf("解析可执行文件路径失败: %w", err)
145+
if err := checkExecutableWritable(execPath); err != nil {
146+
return err
146147
}
147148

148149
// 创建临时目录
@@ -174,6 +175,8 @@ func PerformUpdate(ctx context.Context, info *UpdateInfo) error {
174175
return fmt.Errorf("文件校验失败: %w", err)
175176
}
176177
}
178+
} else {
179+
logger.Warn("未找到 checksum 文件,跳过下载文件校验", "binary", info.BinaryName)
177180
}
178181

179182
// 设置可执行权限(Unix 系统)
@@ -182,25 +185,156 @@ func PerformUpdate(ctx context.Context, info *UpdateInfo) error {
182185
return fmt.Errorf("设置可执行权限失败: %w", err)
183186
}
184187
}
188+
if err := smokeTestBinary(ctx, newBinaryPath); err != nil {
189+
return err
190+
}
185191

186192
// 备份当前版本
187-
backupPath := execPath + ".backup"
188-
if err := copyFile(execPath, backupPath); err != nil {
189-
return fmt.Errorf("备份当前版本失败: %w", err)
193+
backupPath, err := backupExecutable(execPath)
194+
if err != nil {
195+
return err
190196
}
191197

192198
// 替换可执行文件
193199
if err := replaceExecutable(newBinaryPath, execPath); err != nil {
194-
// 恢复备份
195-
if restoreErr := os.Rename(backupPath, execPath); restoreErr != nil {
200+
if restoreErr := restoreBackup(backupPath, execPath); restoreErr != nil {
196201
return fmt.Errorf("替换失败且恢复备份失败: %w, 恢复错误: %v", err, restoreErr)
197202
}
198203
return fmt.Errorf("替换可执行文件失败: %w", err)
199204
}
200205

201-
// 删除备份
202-
os.Remove(backupPath)
206+
logger.Info("更新成功,已保留上一版本备份", "backup", backupPath)
207+
208+
return nil
209+
}
210+
211+
// Rollback 回滚到上一次更新保留的备份版本。
212+
func Rollback() error {
213+
execPath, err := currentExecutablePath()
214+
if err != nil {
215+
return err
216+
}
217+
if err := checkExecutableWritable(execPath); err != nil {
218+
return err
219+
}
220+
221+
backupPath := backupPathFor(execPath)
222+
if _, err := os.Stat(backupPath); err != nil {
223+
if os.IsNotExist(err) {
224+
return fmt.Errorf("未找到可回滚备份: %s", backupPath)
225+
}
226+
return fmt.Errorf("检查回滚备份失败: %w", err)
227+
}
228+
229+
if err := restoreBackup(backupPath, execPath); err != nil {
230+
return fmt.Errorf("回滚失败: %w", err)
231+
}
232+
logger.Info("回滚成功", "backup", backupPath)
233+
return nil
234+
}
235+
236+
// currentExecutablePath 返回当前可执行文件的真实路径。
237+
func currentExecutablePath() (string, error) {
238+
execPath, err := os.Executable()
239+
if err != nil {
240+
return "", fmt.Errorf("获取可执行文件路径失败: %w", err)
241+
}
242+
execPath, err = filepath.EvalSymlinks(execPath)
243+
if err != nil {
244+
return "", fmt.Errorf("解析可执行文件路径失败: %w", err)
245+
}
246+
return execPath, nil
247+
}
248+
249+
// checkExecutableWritable 检查当前二进制和所在目录是否可写。
250+
func checkExecutableWritable(execPath string) error {
251+
if strings.TrimSpace(execPath) == "" {
252+
return fmt.Errorf("可执行文件路径不能为空")
253+
}
203254

255+
info, err := os.Stat(execPath)
256+
if err != nil {
257+
return fmt.Errorf("检查当前二进制失败: %w", err)
258+
}
259+
if info.IsDir() {
260+
return fmt.Errorf("当前二进制路径不是文件: %s", execPath)
261+
}
262+
263+
file, err := os.Open(execPath)
264+
if err != nil {
265+
return fmt.Errorf("当前二进制不可读: %w", err)
266+
}
267+
if err := file.Close(); err != nil {
268+
return fmt.Errorf("关闭当前二进制失败: %w", err)
269+
}
270+
271+
parentDir := filepath.Dir(execPath)
272+
probe, err := os.CreateTemp(parentDir, ".anssl-update-write-*")
273+
if err != nil {
274+
return fmt.Errorf("当前二进制所在目录不可写: %w", err)
275+
}
276+
probePath := probe.Name()
277+
if err := probe.Close(); err != nil {
278+
os.Remove(probePath)
279+
return fmt.Errorf("关闭写权限探测文件失败: %w", err)
280+
}
281+
if err := os.Remove(probePath); err != nil {
282+
return fmt.Errorf("删除写权限探测文件失败: %w", err)
283+
}
284+
return nil
285+
}
286+
287+
// smokeTestBinary 通过 version 命令验证新二进制可执行。
288+
func smokeTestBinary(ctx context.Context, binaryPath string) error {
289+
smokeCtx, cancel := context.WithTimeout(ctx, smokeTestTimeout)
290+
defer cancel()
291+
292+
cmd := exec.CommandContext(smokeCtx, binaryPath, "version")
293+
output, err := cmd.CombinedOutput()
294+
if smokeCtx.Err() == context.DeadlineExceeded {
295+
return fmt.Errorf("新二进制 smoke test 超时")
296+
}
297+
if err != nil {
298+
return fmt.Errorf("新二进制 smoke test 失败: %w\n%s", err, strings.TrimSpace(string(output)))
299+
}
300+
return nil
301+
}
302+
303+
// backupPathFor 返回当前二进制的备份路径。
304+
func backupPathFor(execPath string) string {
305+
return execPath + ".backup"
306+
}
307+
308+
// backupExecutable 备份当前二进制,成功更新后仍保留该备份供 rollback 使用。
309+
func backupExecutable(execPath string) (string, error) {
310+
backupPath := backupPathFor(execPath)
311+
if err := copyFile(execPath, backupPath); err != nil {
312+
return "", fmt.Errorf("备份当前版本失败: %w", err)
313+
}
314+
return backupPath, nil
315+
}
316+
317+
// restoreBackup 使用备份恢复当前二进制,同时保留备份文件。
318+
func restoreBackup(backupPath, execPath string) error {
319+
restorePath := execPath + ".restore"
320+
os.Remove(restorePath)
321+
if err := copyFile(backupPath, restorePath); err != nil {
322+
return fmt.Errorf("准备恢复文件失败: %w", err)
323+
}
324+
if _, err := os.Stat(execPath); os.IsNotExist(err) {
325+
if err := copyFile(restorePath, execPath); err != nil {
326+
os.Remove(restorePath)
327+
return err
328+
}
329+
return os.Remove(restorePath)
330+
} else if err != nil {
331+
os.Remove(restorePath)
332+
return err
333+
}
334+
if err := replaceExecutable(restorePath, execPath); err != nil {
335+
os.Remove(restorePath)
336+
return err
337+
}
204338
return nil
205339
}
206340

internal/updater/updater_test.go

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,14 @@ import (
55
"archive/zip"
66
"bytes"
77
"compress/gzip"
8+
"context"
89
"os"
910
"path/filepath"
11+
"runtime"
1012
"testing"
1113
)
1214

15+
// TestExtractBinaryTarGzFindsExecutableByName 验证 tar.gz 包中可以按名称提取可执行文件。
1316
func TestExtractBinaryTarGzFindsExecutableByName(t *testing.T) {
1417
tempDir := t.TempDir()
1518
archivePath := filepath.Join(tempDir, "anssl-linux-amd64.tar.gz")
@@ -27,6 +30,7 @@ func TestExtractBinaryTarGzFindsExecutableByName(t *testing.T) {
2730
assertFileContent(t, extractedPath, "binary-content")
2831
}
2932

33+
// TestExtractBinaryZipFindsExecutableByName 验证 zip 包中可以按名称提取可执行文件。
3034
func TestExtractBinaryZipFindsExecutableByName(t *testing.T) {
3135
tempDir := t.TempDir()
3236
archivePath := filepath.Join(tempDir, "anssl-windows-amd64.zip")
@@ -44,6 +48,7 @@ func TestExtractBinaryZipFindsExecutableByName(t *testing.T) {
4448
assertFileContent(t, extractedPath, "windows-binary-content")
4549
}
4650

51+
// TestExtractBinaryTarGzMissingExecutable 验证压缩包缺少可执行文件时返回错误。
4752
func TestExtractBinaryTarGzMissingExecutable(t *testing.T) {
4853
tempDir := t.TempDir()
4954
archivePath := filepath.Join(tempDir, "anssl-linux-amd64.tar.gz")
@@ -57,11 +62,85 @@ func TestExtractBinaryTarGzMissingExecutable(t *testing.T) {
5762
}
5863
}
5964

65+
// TestSmokeTestBinaryRunsVersion 验证 smoke test 会执行新二进制的 version 命令。
66+
func TestSmokeTestBinaryRunsVersion(t *testing.T) {
67+
if runtime.GOOS == "windows" {
68+
t.Skip("shell script smoke test is Unix-only")
69+
}
70+
71+
tempDir := t.TempDir()
72+
binaryPath := filepath.Join(tempDir, "anssl")
73+
if err := os.WriteFile(binaryPath, []byte("#!/bin/sh\nif [ \"$1\" = \"version\" ]; then echo v-test; exit 0; fi\nexit 1\n"), 0755); err != nil {
74+
t.Fatalf("write smoke binary: %v", err)
75+
}
76+
77+
if err := smokeTestBinary(context.Background(), binaryPath); err != nil {
78+
t.Fatalf("smokeTestBinary() error = %v", err)
79+
}
80+
}
81+
82+
// TestSmokeTestBinaryFails 验证 smoke test 会拒绝执行失败的新二进制。
83+
func TestSmokeTestBinaryFails(t *testing.T) {
84+
if runtime.GOOS == "windows" {
85+
t.Skip("shell script smoke test is Unix-only")
86+
}
87+
88+
tempDir := t.TempDir()
89+
binaryPath := filepath.Join(tempDir, "anssl")
90+
if err := os.WriteFile(binaryPath, []byte("#!/bin/sh\nexit 1\n"), 0755); err != nil {
91+
t.Fatalf("write smoke binary: %v", err)
92+
}
93+
94+
if err := smokeTestBinary(context.Background(), binaryPath); err == nil {
95+
t.Fatal("smokeTestBinary() error = nil, want error")
96+
}
97+
}
98+
99+
// TestRestoreBackupRestoresMissingExecutable 验证目标文件缺失时可以从备份恢复。
100+
func TestRestoreBackupRestoresMissingExecutable(t *testing.T) {
101+
tempDir := t.TempDir()
102+
execPath := filepath.Join(tempDir, "anssl")
103+
backupPath := backupPathFor(execPath)
104+
if err := os.WriteFile(backupPath, []byte("backup-binary"), 0755); err != nil {
105+
t.Fatalf("write backup: %v", err)
106+
}
107+
108+
if err := restoreBackup(backupPath, execPath); err != nil {
109+
t.Fatalf("restoreBackup() error = %v", err)
110+
}
111+
112+
assertFileContent(t, execPath, "backup-binary")
113+
assertFileContent(t, backupPath, "backup-binary")
114+
}
115+
116+
// TestRestoreBackupReplacesExistingExecutable 验证目标文件存在时可以从备份替换恢复。
117+
func TestRestoreBackupReplacesExistingExecutable(t *testing.T) {
118+
tempDir := t.TempDir()
119+
execPath := filepath.Join(tempDir, "anssl")
120+
backupPath := backupPathFor(execPath)
121+
if err := os.WriteFile(execPath, []byte("current-binary"), 0755); err != nil {
122+
t.Fatalf("write current binary: %v", err)
123+
}
124+
if err := os.WriteFile(backupPath, []byte("backup-binary"), 0755); err != nil {
125+
t.Fatalf("write backup: %v", err)
126+
}
127+
128+
if err := restoreBackup(backupPath, execPath); err != nil {
129+
t.Fatalf("restoreBackup() error = %v", err)
130+
}
131+
132+
assertFileContent(t, execPath, "backup-binary")
133+
assertFileContent(t, backupPath, "backup-binary")
134+
}
135+
60136
type archiveEntry struct {
61-
name string
137+
// name 是压缩包内文件名。
138+
name string
139+
// content 是压缩包内文件内容。
62140
content string
63141
}
64142

143+
// writeTarGzArchive 写入测试用 tar.gz 压缩包。
65144
func writeTarGzArchive(t *testing.T, archivePath string, entries []archiveEntry) {
66145
t.Helper()
67146

@@ -94,6 +173,7 @@ func writeTarGzArchive(t *testing.T, archivePath string, entries []archiveEntry)
94173
}
95174
}
96175

176+
// writeZipArchive 写入测试用 zip 压缩包。
97177
func writeZipArchive(t *testing.T, archivePath string, entries []archiveEntry) {
98178
t.Helper()
99179

@@ -118,6 +198,7 @@ func writeZipArchive(t *testing.T, archivePath string, entries []archiveEntry) {
118198
}
119199
}
120200

201+
// assertFileContent 断言文件内容等于预期字符串。
121202
func assertFileContent(t *testing.T, filePath, want string) {
122203
t.Helper()
123204

0 commit comments

Comments
 (0)