From 0e579fe60a715e1f70c3b2dc776c4742c1e29b28 Mon Sep 17 00:00:00 2001 From: xuans Date: Sat, 3 Oct 2026 05:28:08 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E9=A6=96=E6=AC=A1=E9=83=A8?= =?UTF-8?q?=E7=BD=B2=E6=81=A2=E5=A4=8D=E4=BA=8B=E5=8A=A1=E6=A0=B8=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留首次部署新证书现场并在 HTTPS 核验成功后安全收尾,补充恢复场景测试。 --- backend/internal/deployment/service.go | 32 ++++++++++ .../deployment/transaction_recovery_test.go | 64 +++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/backend/internal/deployment/service.go b/backend/internal/deployment/service.go index a60c7ee..efcd993 100644 --- a/backend/internal/deployment/service.go +++ b/backend/internal/deployment/service.go @@ -666,6 +666,14 @@ func (s *Service) recoverTransaction(ctx context.Context, record *TransactionRec return fmt.Errorf("连接服务器失败:%w", err) } defer client.Close() + if record.Phase == TransactionPhaseReloadAttempted && transactionTargetsWereAbsent(*record) && transactionFilesMatchExpected(recoveryContext, client, *record) { + // 首次部署没有可恢复的旧文件,只能在目标文件和远端站点均确认本次证书后收尾。 + if err := s.recoverCommittedTransaction(recoveryContext, record, server, client); err == nil { + return nil + } else { + return fmt.Errorf("首次部署结果核验失败,保留现场等待人工核查:%w", err) + } + } if record.Phase == TransactionPhaseAcquiring || record.Phase == TransactionPhaseRollbackVerified || record.Phase == TransactionPhaseCommitVerified { return releaseTransactionLocks(recoveryContext, client, *record, true, true) } @@ -746,6 +754,30 @@ func (s *Service) recoverCommittedTransaction(ctx context.Context, record *Trans return releaseTransactionLocks(ctx, client, *record, true, false) } +func transactionTargetsWereAbsent(record TransactionRecord) bool { + if len(record.Backups) == 0 || len(record.Backups) != len(record.Paths) { + return false + } + for _, backup := range record.Backups { + if backup.OriginalExists { + return false + } + } + return true +} + +func transactionFilesMatchExpected(ctx context.Context, client Client, record TransactionRecord) bool { + if record.Evidence == nil || len(record.Paths) == 0 { + return false + } + for _, path := range record.Paths { + expected := record.Evidence.Files[path] + if expected == "" || checkFileDigest(ctx, client, path, expected) != nil { + return false + } + } + return true +} func restoreTransactionFiles(ctx context.Context, client Client, record TransactionRecord) error { if err := validateRollbackFiles(ctx, client, record); err != nil { return err diff --git a/backend/internal/deployment/transaction_recovery_test.go b/backend/internal/deployment/transaction_recovery_test.go index 65b1f7b..3f7f58e 100644 --- a/backend/internal/deployment/transaction_recovery_test.go +++ b/backend/internal/deployment/transaction_recovery_test.go @@ -213,3 +213,67 @@ func TestRecoverPendingAcquiringPreservesLocksWithoutOwner(t *testing.T) { t.Fatal("无归属锁必须保留事务等待人工核查") } } +func TestRecoverPendingCommitsVerifiedFirstDeployment(t *testing.T) { + server, client, record := recoveryPreflightFixture() + for index := range record.Backups { + record.Backups[index].OriginalExists = false + record.Backups[index].OriginalDigest = "" + delete(client.files, record.Backups[index].Saved) + } + journal := &memoryTransactionJournal{pending: []TransactionRecord{record}} + service := NewServiceWithJournal(fixedClientFactory{client}, journal) + service.VerifyRecoveryTarget = func(_ context.Context, _ Server, _ RecoveryBaseline, committed bool) error { + if !committed { + t.Fatal("首次部署现场应按新证书提交核验") + } + return nil + } + if err := service.RecoverPending(t.Context(), func(context.Context, string) (Server, error) { return server, nil }); err != nil { + t.Fatalf("首次部署提交恢复失败:%v", err) + } + if len(journal.finished) != 1 || journal.finished[0].status != TransactionStatusCommitted { + t.Fatalf("首次部署提交状态不正确:%+v", journal.finished) + } + for index, target := range record.Paths { + if _, ok := client.files[target+".scert-lock"]; ok { + t.Fatalf("提交完成后不应残留部署锁:%s", target) + } + if _, ok := client.files[record.TemporaryPaths[index]]; ok { + t.Fatalf("提交完成后不应残留临时文件:%s", record.TemporaryPaths[index]) + } + if string(client.files[target].data) != "new" { + t.Fatalf("提交完成后目标文件被错误修改:%s", target) + } + } +} + +func TestRecoverPendingKeepsFirstDeploymentForManualReviewWhenVerificationFails(t *testing.T) { + server, client, record := recoveryPreflightFixture() + for index := range record.Backups { + record.Backups[index].OriginalExists = false + record.Backups[index].OriginalDigest = "" + delete(client.files, record.Backups[index].Saved) + } + journal := &memoryTransactionJournal{pending: []TransactionRecord{record}} + service := NewServiceWithJournal(fixedClientFactory{client}, journal) + service.VerifyRecoveryTarget = func(context.Context, Server, RecoveryBaseline, bool) error { + return errors.New("站点尚未确认新证书") + } + if err := service.RecoverPending(t.Context(), func(context.Context, string) (Server, error) { return server, nil }); err == nil { + t.Fatal("首次部署站点未确认时必须保留人工核查状态") + } + if len(journal.finished) != 0 || len(journal.updated) != 1 || journal.updated[0].Status != TransactionStatusRecoveryRequired { + t.Fatalf("失败核验状态不正确:%+v", journal) + } + for index, target := range record.Paths { + if _, ok := client.files[target+".scert-lock"]; !ok { + t.Fatalf("失败核验时不得释放部署锁:%s", target) + } + if string(client.files[target].data) != "new" { + t.Fatalf("失败核验时不得删除新目标文件:%s", target) + } + if _, ok := client.files[record.TemporaryPaths[index]]; !ok { + t.Fatalf("失败核验时应保留临时现场:%s", record.TemporaryPaths[index]) + } + } +} \ No newline at end of file