完善部署事务持久化与中断恢复

接入任务上下文、部署事务日志、启动恢复流程及恢复回归测试,保留无法证明归属时的人工核查状态。
This commit is contained in:
xuans 2026-10-01 02:36:58 +08:00
parent 3a0e1d8769
commit 918e3f6d25
6 changed files with 380 additions and 8 deletions

View File

@ -103,6 +103,11 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string
}
}
taskRepo := repository.NewSQLTaskRepository(db.DB, runtimeDatabaseConfig.Driver)
transactionRepo := repository.NewSQLDeploymentTransactionRepository(db.DB, runtimeDatabaseConfig.Driver)
deploymentService := deployment.NewServiceWithJournal(deployment.SSHFactory{Secrets: secretStore}, transactionRepo)
if err := deploymentService.RecoverPending(ctx, serverRepo.GetServerByID); err != nil {
logger.Error("恢复中断部署事务失败,已保留现场等待人工核查", "error", err)
}
if recovered, err := taskRepo.RecoverInterruptedTasks(ctx, time.Now().UTC()); err != nil {
_ = db.Close()
_ = logger.Close()
@ -121,7 +126,7 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string
certificates := certificate.NewService(cfg, repo, secretStore, dnsprovider.NewLegoFactory(), accounts)
aliyunAuthChecker := dnsprovider.NewAliyunAuthorizationChecker()
certificates.SetDNSAuthorizationChecker(aliyunAuthChecker)
deploymentService := deployment.NewService(deployment.SSHFactory{Secrets: secretStore})
notifier := notification.New(cfg.Notification, secretStore)
remoteValidator := certificate.RemoteValidator{}
workflow := &CertificateWorkflow{

View File

@ -147,6 +147,9 @@ func (w *certificateTaskWorkflow) run(ctx context.Context, id, taskType, stage s
return result, err
}
issueCtx, cancel := context.WithTimeout(taskCtx, 30*time.Minute)
issueCtx = deployment.WithTransactionContext(issueCtx, deployment.TransactionContext{
TaskID: task.ID, AttemptID: attempt.ID, AttemptNumber: attempt.AttemptNumber,
})
result, resultErr = execute(issueCtx, id)
cancel()
if recordErr := w.tasks.RecordTaskDeploymentResults(taskCtx, task.ID, attempt.ID, number, taskDeploymentResults(result.Deployment)); recordErr != nil {

View File

@ -52,6 +52,7 @@ type Report struct {
type Service struct {
factory ClientFactory
journal TransactionJournal
now func() time.Time
}
@ -59,6 +60,10 @@ func NewService(factory ClientFactory) *Service {
return &Service{factory: factory, now: time.Now}
}
func NewServiceWithJournal(factory ClientFactory, journal TransactionJournal) *Service {
return &Service{factory: factory, journal: journal, now: time.Now}
}
func (s *Service) Deploy(ctx context.Context, targets []Target, artifact Artifact) Report {
return s.deploy(ctx, targets, artifact, nil)
}
@ -190,13 +195,18 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac
result.Message += fmt.Sprintf(";释放部署锁失败,需人工检查:%v", err)
return
}
released := true
for index := len(heldLocks) - 1; index >= 0; index-- {
if err := client.Unlock(releaseCtx, heldLocks[index]); err != nil {
released = false
result.Status = "failed"
result.FailureCode = FailureLockRelease
result.Message += fmt.Sprintf(";释放部署锁 %s 失败,需人工检查:%v", heldLocks[index], err)
}
}
if released {
heldLocks = nil
}
}
defer func() {
if !handedOff {
@ -213,6 +223,32 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac
}
backupID := s.backupID()
result.BackupID = backupID
journalRecord, transactionEnabled := s.newTransactionRecord(ctx, server, paths, lockPaths, backupID, index)
if transactionEnabled {
if err := s.journal.CreateDeploymentTransaction(ctx, *journalRecord); err != nil {
result.FailureCode = FailureUnavailable
result.Message = fmt.Sprintf("保存部署事务现场失败:%v", err)
return result
}
}
persistTransaction := func(phase string) error {
if !transactionEnabled {
return nil
}
journalRecord.Phase = phase
journalRecord.UpdatedAt = s.now().UTC()
return s.journal.UpdateDeploymentTransaction(ctx, *journalRecord)
}
finishTransaction := func(status, message string) {
if !transactionEnabled {
return
}
if err := s.journal.FinishDeploymentTransaction(context.WithoutCancel(ctx), journalRecord.ID, status, message); err != nil {
result.Status = "failed"
result.FailureCode = FailureRecoveryUnknown
result.Message += fmt.Sprintf(";保存部署事务最终状态失败:%v", err)
}
}
backups := make([]backupFile, 0, len(paths))
temporary := make([]string, 0, len(paths))
installed := make([]string, 0, len(paths))
@ -268,6 +304,12 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac
message += ";服务运行状态未确认,请检查服务并在确认旧配置后重新加载"
}
result.Message = message
releaseLocks()
if retainLocks || result.FailureCode == FailureLockRelease {
finishTransaction(TransactionStatusRecoveryRequired, result.Message)
} else {
finishTransaction(TransactionStatusRolledBack, result.Message)
}
return result
}
result.FailureCode = FailureUpload
@ -275,6 +317,9 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac
temporaryPath := file.path + ".scert-tmp-" + backupID
// 上传失败也可能留下半份私钥,必须在开始上传前登记清理路径。
temporary = append(temporary, temporaryPath)
if transactionEnabled {
journalRecord.TemporaryPaths = append(journalRecord.TemporaryPaths, temporaryPath)
}
if err := client.Upload(ctx, temporaryPath, file.data, file.mode); err != nil {
var notCreated *uploadNotCreatedError
if errors.As(err, &notCreated) {
@ -284,6 +329,9 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac
return failed(fmt.Sprintf("上传远程证书失败:%v", err))
}
}
if err := persistTransaction(TransactionPhaseUploaded); err != nil {
return failed(fmt.Sprintf("保存上传阶段失败:%v", err))
}
// 所有文件上传完成后才触碰当前版本,避免上传失败造成不必要的证书替换。
result.FailureCode = FailureReplace
for _, file := range paths {
@ -291,23 +339,44 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac
if err != nil {
return failed(fmt.Sprintf("检查远程文件失败:%v", err))
}
if exists {
backupPath := file.path + ".scert-backup-" + backupID
if exists, err := client.Exists(ctx, backupPath); err != nil || exists {
return failed(fmt.Sprintf("备份目标已存在或无法检查,已停止替换:%s:%v", backupPath, err))
backupPath := file.path + ".scert-backup-" + backupID
if backupExists, checkErr := client.Exists(ctx, backupPath); checkErr != nil || backupExists {
return failed(fmt.Sprintf("备份目标已存在或无法检查,已停止替换:%s:%v", backupPath, checkErr))
}
if transactionEnabled {
journalRecord.Backups = append(journalRecord.Backups, TransactionBackup{Original: file.path, Saved: backupPath, OriginalExists: exists})
if err := persistTransaction(TransactionPhaseReplacing); err != nil {
return failed(fmt.Sprintf("保存替换阶段失败:%v", err))
}
}
if exists {
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))
}
if err := persistTransaction(TransactionPhaseReplacing); err != nil {
return failed(fmt.Sprintf("保存备份阶段失败:%v", err))
}
}
temporaryPath := file.path + ".scert-tmp-" + backupID
installed = append(installed, file.path)
if transactionEnabled {
journalRecord.InstalledPaths = append(journalRecord.InstalledPaths, file.path)
if err := persistTransaction(TransactionPhaseReplacing); err != nil {
return failed(fmt.Sprintf("保存安装阶段失败:%v", err))
}
}
if err := client.Rename(ctx, temporaryPath, file.path); err != nil {
return failed(fmt.Sprintf("替换远程证书失败:%v", err))
}
if err := persistTransaction(TransactionPhaseInstalled); err != nil {
return failed(fmt.Sprintf("保存已安装阶段失败:%v", err))
}
}
result.FailureCode = FailureValidation
if err := persistTransaction(TransactionPhaseInstalled); err != nil {
return failed(fmt.Sprintf("保存校验前阶段失败:%v", err))
}
validationResult, err := client.Run(ctx, server.ValidationCommand)
if err != nil || validationResult.ExitCode != 0 {
if err == nil {
@ -316,6 +385,12 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac
return failed(fmt.Sprintf("配置校验失败:%v", err))
}
reloadAttempted = true
if transactionEnabled {
journalRecord.ReloadAttempted = true
if err := persistTransaction(TransactionPhaseReloadAttempted); err != nil {
return failed(fmt.Sprintf("保存重载前阶段失败:%v", err))
}
}
commandResult, err := client.Run(ctx, server.ReloadCommand)
if err != nil || commandResult.ExitCode != 0 {
if err == nil {
@ -354,6 +429,12 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac
return result
}
transaction.commit = func(commitContext context.Context) Result {
if err := persistTransaction("committing"); err != nil {
result.Status = "failed"
result.FailureCode = FailureCleanup
result.Message = fmt.Sprintf("保存提交阶段失败:%v", err)
return result
}
if err := cleanupBackups(commitContext, client, backups, paths); err != nil {
result.Status = "failed"
result.FailureCode = FailureCleanup
@ -370,8 +451,18 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac
_ = client.Close()
closed = true
}
status := TransactionStatusCommitted
if retain || result.Status != "success" {
status = TransactionStatusRecoveryRequired
} else if result.Message != "" && result.Status == "failed" {
status = TransactionStatusRolledBack
}
finishTransaction(status, result.Message)
return result
}
if transactionEnabled {
journalRecord.Phase = TransactionPhaseReloadAttempted
}
handedOff = true
*pending = append(*pending, transaction)
return result
@ -379,16 +470,165 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac
if err := cleanupBackups(ctx, client, backups, paths); err != nil {
result.Status = "failed"
result.FailureCode = FailureCleanup
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)
result.Message = fmt.Sprintf("证书已部署并 reload 成功,但旧证书备份清理失败:%v", err)
releaseLocks()
finishTransaction(TransactionStatusRecoveryRequired, result.Message)
return result
}
result.Status = "success"
result.FailureCode = ""
result.Message = "证书部署、配置校验并 reload 成功"
result.BackupID = backupID
releaseLocks()
if result.FailureCode == FailureLockRelease {
finishTransaction(TransactionStatusRecoveryRequired, result.Message)
} else {
finishTransaction(TransactionStatusCommitted, result.Message)
}
return result
}
func (s *Service) newTransactionRecord(ctx context.Context, server Server, paths []remoteFile, lockPaths []string, id string, index int) (*TransactionRecord, bool) {
transactionContext, ok := TransactionContextFromContext(ctx)
if !ok || s == nil || s.journal == nil {
return nil, false
}
pathValues := make([]string, 0, len(paths))
temporaryPaths := make([]string, 0, len(paths))
for _, file := range paths {
pathValues = append(pathValues, string(file.path))
temporaryPaths = append(temporaryPaths, string(file.path)+".scert-tmp-"+id)
}
now := time.Now().UTC()
if s.now != nil {
now = s.now().UTC()
}
return &TransactionRecord{
ID: id, TaskID: transactionContext.TaskID, AttemptID: transactionContext.AttemptID,
AttemptNumber: transactionContext.AttemptNumber, ResultIndex: index,
ServerID: server.ID, ServerName: server.Name, Paths: pathValues,
TemporaryPaths: temporaryPaths, LockPaths: append([]string(nil), lockPaths...),
Phase: TransactionPhaseLocked, Status: TransactionStatusPending, UpdatedAt: now,
}, true
}
// RecoverPending 在服务启动时只处理能够证明属于本次事务的现场;无法证明时保留现场并要求人工核查。
func (s *Service) RecoverPending(ctx context.Context, resolve func(context.Context, string) (Server, error)) error {
if s == nil || s.journal == nil {
return nil
}
if resolve == nil {
return errors.New("部署事务恢复缺少服务器解析器")
}
records, err := s.journal.ListPendingDeploymentTransactions(ctx)
if err != nil {
return fmt.Errorf("读取待恢复部署事务失败:%w", err)
}
var recoveryErrors []error
for _, record := range records {
if err := s.recoverTransaction(ctx, record, resolve); err != nil {
recoveryErrors = append(recoveryErrors, fmt.Errorf("事务 %s 恢复失败:%w", record.ID, err))
record.Status = TransactionStatusRecoveryRequired
record.Phase = TransactionStatusRecoveryRequired
record.Message = err.Error()
if updateErr := s.journal.UpdateDeploymentTransaction(context.WithoutCancel(ctx), record); updateErr != nil {
recoveryErrors = append(recoveryErrors, fmt.Errorf("事务 %s 保存人工核查状态失败:%w", record.ID, updateErr))
}
continue
}
if err := s.journal.FinishDeploymentTransaction(context.WithoutCancel(ctx), record.ID, TransactionStatusRolledBack, "服务启动已完成中断部署事务回滚"); err != nil {
recoveryErrors = append(recoveryErrors, fmt.Errorf("事务 %s 保存恢复结果失败:%w", record.ID, err))
}
}
return errors.Join(recoveryErrors...)
}
func (s *Service) recoverTransaction(ctx context.Context, record TransactionRecord, resolve func(context.Context, string) (Server, error)) error {
server, err := resolve(ctx, record.ServerID)
if err != nil {
return fmt.Errorf("读取服务器配置失败:%w", err)
}
server = server.Normalize()
if err := server.Validate(); err != nil {
return fmt.Errorf("服务器配置无效:%w", err)
}
recoveryContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), deploymentRollbackTimeout)
defer cancel()
client, err := s.factory.Connect(recoveryContext, server)
if err != nil {
return fmt.Errorf("连接服务器失败:%w", err)
}
defer client.Close()
for _, lockPath := range record.LockPaths {
exists, checkErr := client.Exists(recoveryContext, lockPath)
if checkErr != nil || !exists {
if checkErr == nil {
checkErr = errors.New("部署锁不存在")
}
return fmt.Errorf("无法证明事务仍拥有部署锁 %s:%w", lockPath, checkErr)
}
}
for _, temporaryPath := range record.TemporaryPaths {
if exists, checkErr := client.Exists(recoveryContext, temporaryPath); checkErr != nil {
return fmt.Errorf("检查临时文件失败:%w", checkErr)
} else if exists {
if err := client.Remove(recoveryContext, temporaryPath); err != nil {
return fmt.Errorf("删除临时文件失败:%w", err)
}
}
}
for index := len(record.Backups) - 1; index >= 0; index-- {
backup := record.Backups[index]
backupExists, checkErr := client.Exists(recoveryContext, backup.Saved)
if checkErr != nil {
return fmt.Errorf("检查备份文件失败:%w", checkErr)
}
targetExists, checkErr := client.Exists(recoveryContext, backup.Original)
if checkErr != nil {
return fmt.Errorf("检查目标文件失败:%w", checkErr)
}
if backup.OriginalExists {
if !backupExists {
if targetExists {
continue
}
return fmt.Errorf("备份文件和目标文件均不存在:%s", backup.Original)
}
if targetExists {
if err := client.Remove(recoveryContext, backup.Original); err != nil {
return fmt.Errorf("删除待恢复目标文件失败:%w", err)
}
}
if err := client.Rename(recoveryContext, backup.Saved, backup.Original); err != nil {
return fmt.Errorf("恢复备份文件失败:%w", err)
}
continue
}
if targetExists {
if err := client.Remove(recoveryContext, backup.Original); err != nil {
return fmt.Errorf("删除新增目标文件失败:%w", err)
}
}
}
if record.ReloadAttempted {
for _, command := range [][]string{server.ValidationCommand, server.ReloadCommand} {
result, runErr := client.Run(recoveryContext, command)
if runErr != nil || result.ExitCode != 0 {
if runErr == nil {
runErr = fmt.Errorf("退出码 %d", result.ExitCode)
}
return fmt.Errorf("恢复服务失败:%w", runErr)
}
}
}
for index := len(record.LockPaths) - 1; index >= 0; index-- {
if err := client.Unlock(recoveryContext, record.LockPaths[index]); err != nil {
return fmt.Errorf("释放部署锁失败:%w", err)
}
}
return nil
}
func cleanupBackups(ctx context.Context, client Client, backups []backupFile, paths []remoteFile) error {
seen := make(map[string]struct{}, len(backups))
for _, item := range backups {

View File

@ -8,8 +8,9 @@ import (
// TransactionBackup 描述一次部署事务保存的旧文件。
type TransactionBackup struct {
Original string `json:"original"`
Saved string `json:"saved"`
Original string `json:"original"`
Saved string `json:"saved"`
OriginalExists bool `json:"original_exists"`
}
// TransactionRecord 保存远程部署事务的可恢复现场,不包含证书内容或凭据。
@ -41,6 +42,21 @@ type TransactionJournal interface {
ListPendingDeploymentTransactions(context.Context) ([]TransactionRecord, error)
}
const (
TransactionStatusPending = "pending"
TransactionStatusCommitted = "committed"
TransactionStatusRolledBack = "rolled_back"
TransactionStatusRecoveryRequired = "recovery_required"
TransactionPhaseLocked = "locked"
TransactionPhaseUploaded = "uploaded"
TransactionPhaseReplacing = "replacing"
TransactionPhaseInstalled = "installed"
TransactionPhaseReloadAttempted = "reload_attempted"
TransactionPhaseCommitted = "committed"
TransactionPhaseRolledBack = "rolled_back"
)
type transactionContextKey struct{}
// TransactionContext 保存任务与尝试标识,供部署层建立可追溯事务记录。

View File

@ -0,0 +1,105 @@
package deployment
import (
"context"
"testing"
)
type memoryTransactionJournal struct {
pending []TransactionRecord
finished []struct {
id string
status string
message string
}
updated []TransactionRecord
}
func (j *memoryTransactionJournal) CreateDeploymentTransaction(_ context.Context, item TransactionRecord) error {
j.pending = append(j.pending, item)
return nil
}
func (j *memoryTransactionJournal) UpdateDeploymentTransaction(_ context.Context, item TransactionRecord) error {
j.updated = append(j.updated, item)
return nil
}
func (j *memoryTransactionJournal) FinishDeploymentTransaction(_ context.Context, id, status, message string) error {
j.finished = append(j.finished, struct {
id string
status string
message string
}{id: id, status: status, message: message})
return nil
}
func (j *memoryTransactionJournal) ListPendingDeploymentTransactions(context.Context) ([]TransactionRecord, error) {
return append([]TransactionRecord(nil), j.pending...), nil
}
func TestRecoverPendingRollsBackOwnedDeployment(t *testing.T) {
server := testServer("recovery")
client := newMemoryClient()
id := "recovery-transaction"
paths := []string{server.CertificatePath, server.CertificateChainPath, server.PrivateKeyPath}
locks := make([]string, 0, len(paths))
backups := make([]TransactionBackup, 0, len(paths))
temporary := make([]string, 0, len(paths))
for _, path := range paths {
lockPath := path + ".scert-lock"
backupPath := path + ".scert-backup-" + id
temporaryPath := path + ".scert-tmp-" + id
locks = append(locks, lockPath)
backups = append(backups, TransactionBackup{Original: path, Saved: backupPath, OriginalExists: true})
temporary = append(temporary, temporaryPath)
client.files[lockPath] = memoryFile{}
client.files[backupPath] = memoryFile{data: []byte("old"), mode: 0o600}
client.files[path] = memoryFile{data: []byte("new"), mode: 0o600}
client.files[temporaryPath] = memoryFile{data: []byte("partial"), mode: 0o600}
}
journal := &memoryTransactionJournal{pending: []TransactionRecord{{
ID: id, TaskID: "task", AttemptID: "attempt", AttemptNumber: 1, ResultIndex: 0,
ServerID: server.ID, ServerName: server.Name, Paths: paths, TemporaryPaths: temporary,
InstalledPaths: paths, Backups: backups, LockPaths: locks, ReloadAttempted: true,
Phase: TransactionPhaseReloadAttempted, Status: TransactionStatusPending,
}}}
service := NewServiceWithJournal(memoryFactory{clients: map[string]*memoryClient{server.ID: client}}, journal)
if err := service.RecoverPending(t.Context(), func(context.Context, string) (Server, error) { return server, nil }); err != nil {
t.Fatalf("事务恢复失败:%v", err)
}
for _, path := range paths {
file, ok := client.files[path]
if !ok || string(file.data) != "old" {
t.Fatalf("目标文件未恢复:%s,文件:%+v", path, client.files)
}
if _, ok := client.files[path+".scert-lock"]; ok {
t.Fatalf("恢复成功后不应残留锁:%s", path)
}
if _, ok := client.files[path+".scert-tmp-"+id]; ok {
t.Fatalf("恢复成功后不应残留临时文件:%s", path)
}
}
if len(client.commands) != 2 || len(journal.finished) != 1 || journal.finished[0].status != TransactionStatusRolledBack {
t.Fatalf("恢复收尾记录不正确:命令=%d,完成记录=%+v", len(client.commands), journal.finished)
}
}
func TestRecoverPendingKeepsUnprovableState(t *testing.T) {
server := testServer("unprovable")
client := newMemoryClient()
journal := &memoryTransactionJournal{pending: []TransactionRecord{{
ID: "unprovable-transaction", TaskID: "task", AttemptID: "attempt", AttemptNumber: 1,
ResultIndex: 0, ServerID: server.ID, ServerName: server.Name,
Paths: []string{server.CertificatePath, server.CertificateChainPath, server.PrivateKeyPath},
LockPaths: []string{server.PrivateKeyPath + ".scert-lock"}, Status: TransactionStatusPending,
Phase: TransactionPhaseInstalled,
}}}
service := NewServiceWithJournal(memoryFactory{clients: map[string]*memoryClient{server.ID: client}}, journal)
if err := service.RecoverPending(t.Context(), func(context.Context, string) (Server, error) { return server, nil }); err == nil {
t.Fatal("无法证明锁归属时必须返回人工核查错误")
}
if len(journal.updated) != 1 || journal.updated[0].Status != TransactionStatusRecoveryRequired {
t.Fatalf("未记录人工核查状态:%+v", journal.updated)
}
}

View File

@ -138,6 +138,9 @@ func (s *Server) issueDomainAsTask(c *gin.Context) {
func (s *Server) executeCertificateIssueTask(c *gin.Context, task repository.Task, attempt repository.TaskAttempt, auditAction string) {
executionCtx, cancel := context.WithTimeout(c.Request.Context(), certificateTaskTimeout)
executionCtx = deployment.WithTransactionContext(executionCtx, deployment.TransactionContext{
TaskID: task.ID, AttemptID: attempt.ID, AttemptNumber: attempt.AttemptNumber,
})
s.registerTaskCancel(task.ID, cancel)
defer func() {
cancel()