From f5ce3077d1502d08cdfc15b51e11f9d0f49728bd Mon Sep 17 00:00:00 2001 From: xuans Date: Sun, 27 Sep 2026 09:53:54 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B8=85=E7=90=86=E8=AF=81=E4=B9=A6=E9=83=A8?= =?UTF-8?q?=E7=BD=B2=E6=88=90=E5=8A=9F=E5=90=8E=E7=9A=84=E6=97=A7=E5=A4=87?= =?UTF-8?q?=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 提交证书部署完成后的旧备份清理逻辑及对应测试,保留当前证书并对清理失败显式报错。 --- backend/internal/deployment/client.go | 1 + backend/internal/deployment/service.go | 48 ++++++++++++++++-- backend/internal/deployment/service_test.go | 54 ++++++++++++++++++++- backend/internal/deployment/ssh.go | 15 ++++++ 4 files changed, 113 insertions(+), 5 deletions(-) diff --git a/backend/internal/deployment/client.go b/backend/internal/deployment/client.go index 8a51c6e..505361b 100644 --- a/backend/internal/deployment/client.go +++ b/backend/internal/deployment/client.go @@ -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 diff --git a/backend/internal/deployment/service.go b/backend/internal/deployment/service.go index db7b998..c97089b 100644 --- a/backend/internal/deployment/service.go +++ b/backend/internal/deployment/service.go @@ -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 { diff --git a/backend/internal/deployment/service_test.go b/backend/internal/deployment/service_test.go index 756f523..9d458e4 100644 --- a/backend/internal/deployment/service_test.go +++ b/backend/internal/deployment/service_test.go @@ -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}} diff --git a/backend/internal/deployment/ssh.go b/backend/internal/deployment/ssh.go index 78a2550..076afcb 100644 --- a/backend/internal/deployment/ssh.go +++ b/backend/internal/deployment/ssh.go @@ -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) })