修复证书部署校验重试与状态恢复

This commit is contained in:
xuans 2026-08-31 15:28:27 +08:00
parent 1951d48493
commit aaaf7c3a81
2 changed files with 236 additions and 13 deletions

View File

@ -14,16 +14,24 @@ import (
)
type CertificateWorkflow struct {
Certificates certificateService
Domains domainDeploymentRepository
Servers serverLister
Deployment savedDeployer
Notifier notification.Notifier
NotifyError func(error)
RemoteVerifier func(context.Context, string, []string) (certificate.ValidationResult, error)
CurrentCertificate func(context.Context, string) (certificate.IssueResult, error)
Certificates certificateService
Domains domainDeploymentRepository
Servers serverLister
Deployment savedDeployer
Notifier notification.Notifier
NotifyError func(error)
RemoteVerifier func(context.Context, string, []string) (certificate.ValidationResult, error)
RemoteVerificationAttempts int
RemoteVerificationInterval time.Duration
RemoteVerificationWait func(context.Context, time.Duration) error
CurrentCertificate func(context.Context, string) (certificate.IssueResult, error)
}
const (
defaultRemoteVerificationAttempts = 6
defaultRemoteVerificationInterval = 500 * time.Millisecond
)
type certificateService interface {
Issue(context.Context, string) (certificate.IssueResult, error)
Import(context.Context, certificate.ImportRequest) (certificate.IssueResult, error)
@ -95,6 +103,8 @@ func (w *CertificateWorkflow) DeployCurrent(ctx context.Context, domainID string
report, err := w.deploy(ctx, result)
if err != nil {
w.markDeploymentError(result)
} else if updateErr := w.Domains.UpdateCertificateState(ctx, result.DomainID, "valid", &result.Saved.NotAfter); updateErr != nil {
err = fmt.Errorf("证书部署成功,但更新域名证书状态失败:%w", updateErr)
}
status := "success"
if err != nil {
@ -181,13 +191,58 @@ func (w *CertificateWorkflow) deploy(ctx context.Context, result certificate.Iss
if verificationDomain == "" {
return report, errors.New("无法确定部署后的 HTTPS 校验域名")
}
if _, err := w.RemoteVerifier(ctx, "https://"+verificationDomain, result.Domains); err != nil {
if err := w.verifyRemoteDeployment(ctx, "https://"+verificationDomain, result.Domains); err != nil {
return report, fmt.Errorf("部署后 HTTPS 校验失败:%w", err)
}
}
return report, nil
}
func (w *CertificateWorkflow) verifyRemoteDeployment(ctx context.Context, endpoint string, expectedDomains []string) error {
attempts := w.RemoteVerificationAttempts
if attempts <= 0 {
attempts = defaultRemoteVerificationAttempts
}
interval := w.RemoteVerificationInterval
if interval <= 0 {
interval = defaultRemoteVerificationInterval
}
wait := w.RemoteVerificationWait
if wait == nil {
wait = waitForRemoteVerification
}
var lastErr error
for attempt := 1; attempt <= attempts; attempt++ {
if err := ctx.Err(); err != nil {
return err
}
if _, err := w.RemoteVerifier(ctx, endpoint, expectedDomains); err == nil {
return nil
} else {
lastErr = err
}
if attempt == attempts {
break
}
if err := wait(ctx, interval); err != nil {
return fmt.Errorf("等待 HTTPS 校验重试时被中断:%w", err)
}
}
return fmt.Errorf("重试 %d 次后仍未通过:%w", attempts, lastErr)
}
func waitForRemoteVerification(ctx context.Context, interval time.Duration) error {
timer := time.NewTimer(interval)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func verificationName(item domain.Domain, issuedDomains []string) string {
for _, value := range issuedDomains {
value = strings.TrimSpace(value)

View File

@ -2,6 +2,8 @@ package app
import (
"context"
"errors"
"strings"
"testing"
"time"
@ -40,9 +42,12 @@ type workflowDeployer struct {
}
type workflowDomainState struct {
id string
status string
item domain.Domain
id string
status string
expiresAt *time.Time
updateErr error
updateCalled int
item domain.Domain
}
func (s *workflowDomainState) GetByID(_ context.Context, id string) (domain.Domain, error) {
@ -59,8 +64,16 @@ func (s *workflowDomainState) GetByID(_ context.Context, id string) (domain.Doma
return item, nil
}
func (s *workflowDomainState) UpdateCertificateState(_ context.Context, id, status string, _ *time.Time) error {
func (s *workflowDomainState) UpdateCertificateState(_ context.Context, id, status string, expiresAt *time.Time) error {
s.updateCalled++
if s.updateErr != nil {
return s.updateErr
}
s.id, s.status = id, status
if expiresAt != nil {
value := *expiresAt
s.expiresAt = &value
}
return nil
}
@ -143,3 +156,158 @@ func TestCertificateWorkflowResolvesSameServerForDifferentDomains(t *testing.T)
t.Fatalf("共享服务器的域名部署路径发生覆盖:%s %s", firstPath, secondPath)
}
}
func TestCertificateWorkflowRetriesRemoteVerificationUntilSuccess(t *testing.T) {
deployer := &workflowDeployer{report: deployment.Report{Total: 1, Success: 1, Status: "success"}}
state := &workflowDomainState{item: domain.Domain{ID: "domain-1", Name: "example.com", CertificateType: domain.CertificateSingle}}
verificationCalls := 0
waitCalls := 0
workflow := &CertificateWorkflow{
Certificates: &workflowCertificateService{result: certificate.IssueResult{DomainID: "domain-1", Domains: []string{"example.com"}}},
Domains: state,
Servers: workflowServerLister{servers: []deployment.Server{{ID: "server-1"}}},
Deployment: deployer,
RemoteVerificationAttempts: 4,
RemoteVerificationInterval: 10 * time.Millisecond,
RemoteVerificationWait: func(ctx context.Context, interval time.Duration) error {
waitCalls++
if interval != 10*time.Millisecond {
t.Fatalf("HTTPS 校验重试间隔错误:%s", interval)
}
return ctx.Err()
},
RemoteVerifier: func(_ context.Context, endpoint string, expectedDomains []string) (certificate.ValidationResult, error) {
verificationCalls++
if endpoint != "https://example.com" || len(expectedDomains) != 1 || expectedDomains[0] != "example.com" {
t.Fatalf("HTTPS 校验参数错误:%s %+v", endpoint, expectedDomains)
}
if verificationCalls < 3 {
return certificate.ValidationResult{}, errors.New("仍返回旧证书")
}
return certificate.ValidationResult{}, nil
},
}
if _, err := workflow.IssueWithReport(context.Background(), "domain-1"); err != nil {
t.Fatalf("HTTPS 校验重试后应成功:%v", err)
}
if verificationCalls != 3 || waitCalls != 2 {
t.Fatalf("HTTPS 校验重试次数错误:校验 %d 次,等待 %d 次", verificationCalls, waitCalls)
}
}
func TestCertificateWorkflowRemoteVerificationStopsAfterConfiguredAttempts(t *testing.T) {
deployer := &workflowDeployer{report: deployment.Report{Total: 1, Success: 1, Status: "success"}}
state := &workflowDomainState{item: domain.Domain{ID: "domain-1", Name: "example.com", CertificateType: domain.CertificateSingle}}
verificationCalls := 0
waitCalls := 0
workflow := &CertificateWorkflow{
Certificates: &workflowCertificateService{result: certificate.IssueResult{DomainID: "domain-1", Domains: []string{"example.com"}}},
Domains: state,
Servers: workflowServerLister{servers: []deployment.Server{{ID: "server-1"}}},
Deployment: deployer,
RemoteVerificationAttempts: 3,
RemoteVerificationWait: func(context.Context, time.Duration) error {
waitCalls++
return nil
},
RemoteVerifier: func(context.Context, string, []string) (certificate.ValidationResult, error) {
verificationCalls++
return certificate.ValidationResult{}, errors.New("证书永久不匹配")
},
}
_, err := workflow.IssueWithReport(context.Background(), "domain-1")
if err == nil || !strings.Contains(err.Error(), "重试 3 次后仍未通过") {
t.Fatalf("永久校验错误应在有限重试后返回:%v", err)
}
if verificationCalls != 3 || waitCalls != 2 {
t.Fatalf("永久错误的重试次数错误:校验 %d 次,等待 %d 次", verificationCalls, waitCalls)
}
}
func TestCertificateWorkflowRemoteVerificationWaitHonorsContextCancellation(t *testing.T) {
deployer := &workflowDeployer{report: deployment.Report{Total: 1, Success: 1, Status: "success"}}
state := &workflowDomainState{item: domain.Domain{ID: "domain-1", Name: "example.com", CertificateType: domain.CertificateSingle}}
ctx, cancel := context.WithCancel(context.Background())
verificationCalls := 0
workflow := &CertificateWorkflow{
Certificates: &workflowCertificateService{result: certificate.IssueResult{DomainID: "domain-1", Domains: []string{"example.com"}}},
Domains: state,
Servers: workflowServerLister{servers: []deployment.Server{{ID: "server-1"}}},
Deployment: deployer,
RemoteVerificationAttempts: 6,
RemoteVerificationInterval: time.Minute,
RemoteVerifier: func(context.Context, string, []string) (certificate.ValidationResult, error) {
verificationCalls++
cancel()
return certificate.ValidationResult{}, errors.New("等待新证书")
},
}
_, err := workflow.IssueWithReport(ctx, "domain-1")
if !errors.Is(err, context.Canceled) {
t.Fatalf("取消上下文后应立即终止 HTTPS 校验重试:%v", err)
}
if verificationCalls != 1 {
t.Fatalf("取消后不应继续校验,实际调用 %d 次", verificationCalls)
}
}
func TestCertificateWorkflowDeployCurrentRestoresValidState(t *testing.T) {
notAfter := time.Now().UTC().Add(30 * 24 * time.Hour).Truncate(time.Second)
state := &workflowDomainState{
status: "error",
item: domain.Domain{ID: "domain-1", Name: "example.com", CertificateType: domain.CertificateSingle},
}
workflow := &CertificateWorkflow{
CurrentCertificate: func(context.Context, string) (certificate.IssueResult, error) {
return certificate.IssueResult{
DomainID: "domain-1",
Domains: []string{"example.com"},
Saved: certificate.SavedResource{NotAfter: notAfter},
}, nil
},
Domains: state,
Servers: workflowServerLister{servers: []deployment.Server{{ID: "server-1"}}},
Deployment: &workflowDeployer{report: deployment.Report{Total: 1, Success: 1, Status: "success"}},
RemoteVerifier: func(context.Context, string, []string) (certificate.ValidationResult, error) {
return certificate.ValidationResult{}, nil
},
}
result, err := workflow.DeployCurrent(context.Background(), "domain-1")
if err != nil {
t.Fatalf("当前证书重新部署应成功:%v", err)
}
if result.Deployment.Status != "success" || state.id != "domain-1" || state.status != "valid" || state.updateCalled != 1 {
t.Fatalf("成功部署后域名状态未恢复:%+v %+v", result.Deployment, state)
}
if state.expiresAt == nil || !state.expiresAt.Equal(notAfter) {
t.Fatalf("成功部署后证书到期时间未更新:%v", state.expiresAt)
}
}
func TestCertificateWorkflowDeployCurrentReturnsStateUpdateError(t *testing.T) {
state := &workflowDomainState{
status: "error",
updateErr: errors.New("数据库不可写"),
item: domain.Domain{ID: "domain-1", Name: "example.com", CertificateType: domain.CertificateSingle},
}
workflow := &CertificateWorkflow{
CurrentCertificate: func(context.Context, string) (certificate.IssueResult, error) {
return certificate.IssueResult{DomainID: "domain-1"}, nil
},
Domains: state,
Servers: workflowServerLister{servers: []deployment.Server{{ID: "server-1"}}},
Deployment: &workflowDeployer{report: deployment.Report{Total: 1, Success: 1, Status: "success"}},
}
result, err := workflow.DeployCurrent(context.Background(), "domain-1")
if err == nil || !strings.Contains(err.Error(), "更新域名证书状态失败") {
t.Fatalf("域名状态更新失败时应返回明确错误:%v", err)
}
if result.Deployment.Status != "success" || state.updateCalled != 1 || state.status != "error" {
t.Fatalf("状态更新失败时应保留部署报告和原状态,且不能静默忽略:%+v %+v", result.Deployment, state)
}
}