完善首次部署恢复事务核验

保留首次部署新证书现场并在 HTTPS 核验成功后安全收尾,补充恢复场景测试。
This commit is contained in:
xuans 2026-10-03 05:28:08 +08:00
parent 7469a622b1
commit 0e579fe60a
2 changed files with 96 additions and 0 deletions

View File

@ -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

View File

@ -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])
}
}
}