清理证书部署成功后的旧备份

提交证书部署完成后的旧备份清理逻辑及对应测试,保留当前证书并对清理失败显式报错。
This commit is contained in:
xuans 2026-09-27 09:53:54 +08:00
parent 7adc163e8b
commit f5ce3077d1
4 changed files with 113 additions and 5 deletions

View File

@ -11,6 +11,7 @@ type Client interface {
Lock(ctx context.Context, path string) error
Unlock(ctx context.Context, path string) error
MkdirAll(ctx context.Context, path string) error
List(ctx context.Context, path string) ([]string, error)
Exists(ctx context.Context, path string) (bool, error)
Upload(ctx context.Context, path string, data []byte, mode uint32) error
Rename(ctx context.Context, source, target string) error

View File

@ -178,8 +178,7 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac
}
backupID := s.backupID()
result.BackupID = backupID
type backup struct{ original, saved string }
backups := make([]backup, 0, len(paths))
backups := make([]backupFile, 0, len(paths))
temporary := make([]string, 0, len(paths))
installed := make([]string, 0, len(paths))
rollback := func() error {
@ -256,7 +255,7 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac
if exists, err := client.Exists(ctx, backupPath); err != nil || exists {
return failed(fmt.Sprintf("备份目标已存在或无法检查,已停止替换:%s:%v", backupPath, err))
}
backups = append(backups, backup{original: file.path, saved: backupPath})
backups = append(backups, backupFile{original: file.path, saved: backupPath})
if err := client.Rename(ctx, file.path, backupPath); err != nil {
return failed(fmt.Sprintf("备份远程证书失败:%v", err))
}
@ -282,12 +281,50 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac
}
return failed(fmt.Sprintf("reload 失败:%v", err))
}
if err := cleanupBackups(ctx, client, backups, paths); err != nil {
result.Status = "failed"
result.Message = fmt.Sprintf("\u8bc1\u4e66\u5df2\u90e8\u7f72\u5e76 reload \u6210\u529f\uff0c\u4f46\u65e7\u8bc1\u4e66\u5907\u4efd\u6e05\u7406\u5931\u8d25\uff1a%v", err)
return result
}
result.Status = "success"
result.Message = "证书部署、配置校验并 reload 成功"
result.BackupID = backupID
return result
}
func cleanupBackups(ctx context.Context, client Client, backups []backupFile, paths []remoteFile) error {
seen := make(map[string]struct{}, len(backups))
for _, item := range backups {
if _, exists := seen[item.saved]; exists {
continue
}
seen[item.saved] = struct{}{}
if err := client.Remove(ctx, item.saved); err != nil {
return fmt.Errorf("\u5220\u9664\u672c\u6b21\u5907\u4efd %s \u5931\u8d25\uff1a%w", item.saved, err)
}
}
for _, file := range paths {
directory := parentPath(file.path)
entries, err := client.List(ctx, directory)
if err != nil {
return fmt.Errorf("\u5217\u51fa\u8bc1\u4e66\u76ee\u5f55 %s \u5931\u8d25\uff1a%w", directory, err)
}
prefix := file.path + ".scert-backup-"
for _, entry := range entries {
if !strings.HasPrefix(entry, prefix) {
continue
}
if _, exists := seen[entry]; exists {
continue
}
seen[entry] = struct{}{}
if err := client.Remove(ctx, entry); err != nil {
return fmt.Errorf("\u5220\u9664\u5386\u53f2\u5907\u4efd %s \u5931\u8d25\uff1a%w", entry, err)
}
}
}
return nil
}
func reconnectDeploymentClient(ctx context.Context, factory ClientFactory, server Server, client *Client) error {
if state, ok := (*client).(interface{ Disconnected() bool }); ok && state.Disconnected() {
_ = (*client).Close()
@ -306,6 +343,11 @@ type remoteFile struct {
mode uint32
}
type backupFile struct {
original string
saved string
}
func validateDeploymentPaths(files []remoteFile) error {
seen := make(map[string]struct{}, len(files))
for _, file := range files {

View File

@ -38,6 +38,35 @@ func newMemoryClient() *memoryClient {
func (c *memoryClient) MkdirAll(context.Context, string) error { return nil }
func (c *memoryClient) List(_ context.Context, directory string) ([]string, error) {
entries := make([]string, 0)
prefix := strings.TrimSuffix(directory, "/") + "/"
for filePath := range c.files {
if !strings.HasPrefix(filePath, prefix) {
continue
}
name := strings.TrimPrefix(filePath, prefix)
if name == "" || strings.Contains(name, "/") {
continue
}
entries = append(entries, filePath)
}
return entries, nil
}
func (c *memoryClient) Lock(_ context.Context, path string) error {
if _, exists := c.files[path]; exists {
return errors.New("模拟锁已存在")
@ -118,12 +147,15 @@ func (f memoryFactory) Connect(_ context.Context, server Server) (Client, error)
return f.clients[server.ID], nil
}
func TestDeployReplacesFilesAndKeepsBackup(t *testing.T) {
func TestDeployReplacesFilesAndCleansBackups(t *testing.T) {
client := newMemoryClient()
server := testServer("server-1")
client.files[server.CertificatePath] = memoryFile{data: []byte("old-cert"), mode: 0o644}
client.files[server.CertificateChainPath] = memoryFile{data: []byte("old-chain"), mode: 0o644}
client.files[server.PrivateKeyPath] = memoryFile{data: []byte("old-key"), mode: 0o600}
client.files[server.CertificatePath+".scert-backup-old-1"] = memoryFile{data: []byte("stale-cert"), mode: 0o644}
client.files[server.CertificateChainPath+".scert-backup-old-1"] = memoryFile{data: []byte("stale-chain"), mode: 0o644}
client.files[server.PrivateKeyPath+".scert-backup-old-1"] = memoryFile{data: []byte("stale-key"), mode: 0o600}
service := NewService(memoryFactory{clients: map[string]*memoryClient{server.ID: client}})
service.now = func() time.Time { return time.Date(2026, 8, 27, 1, 2, 3, 4, time.UTC) }
artifact := testArtifact(t)
@ -143,11 +175,29 @@ func TestDeployReplacesFilesAndKeepsBackup(t *testing.T) {
backupCount++
}
}
if backupCount != 3 {
if backupCount != 0 {
t.Fatalf("成功部署后应保留三份远程备份:%d", backupCount)
}
}
func TestDeployReportsBackupCleanupFailure(t *testing.T) {
client := newMemoryClient()
client.removeError = ".scert-backup-"
server := testServer("server-1")
client.files[server.CertificatePath] = memoryFile{data: []byte("old-cert"), mode: 0o644}
service := NewService(memoryFactory{clients: map[string]*memoryClient{server.ID: client}})
report := service.Deploy(context.Background(), []Target{{Server: server}}, testArtifact(t))
if report.Status != "failed" || report.Failed != 1 {
t.Fatalf("backup cleanup failure result is wrong: %+v", report)
}
if !strings.Contains(report.Results[0].Message, "\u65e7\u8bc1\u4e66\u5907\u4efd\u6e05\u7406\u5931\u8d25") {
t.Fatalf("backup cleanup failure was not reported: %s", report.Results[0].Message)
}
if string(client.files[server.CertificatePath].data) == "old-cert" {
t.Fatal("backup cleanup failure must not silently report success")
}
}
func TestDeployRollsBackWhenReloadFails(t *testing.T) {
client := newMemoryClient()
client.runResults = []CommandResult{{ExitCode: 0}, {ExitCode: 1}}

View File

@ -7,6 +7,7 @@ import (
"io"
"net"
"os"
"path"
"strings"
"sync/atomic"
"time"
@ -252,6 +253,20 @@ func (c *sshClientAdapter) MkdirAll(ctx context.Context, path string) error {
return c.fileOperation(ctx, func() error { return c.sftp.MkdirAll(path) })
}
func (c *sshClientAdapter) List(ctx context.Context, directory string) ([]string, error) {
return sshOperation(ctx, c, func() ([]string, error) {
entries, err := c.sftp.ReadDir(directory)
if err != nil {
return nil, err
}
paths := make([]string, 0, len(entries))
for _, entry := range entries {
paths = append(paths, path.Join(directory, entry.Name()))
}
return paths, nil
})
}
// 远程原子建目录用于协作锁,跨进程共享;已有锁绝不自动抢占或清理。
func (c *sshClientAdapter) Lock(ctx context.Context, path string) error {
return c.fileOperation(ctx, func() error { return c.sftp.Mkdir(path) })