完善部署中断恢复提示和安全阶段诊断并记录实机边界
This commit is contained in:
parent
b27eedc1d0
commit
6efc89061b
@ -87,7 +87,7 @@ func TestNewWithConfigPathRecoversInterruptedTasks(t *testing.T) {
|
||||
if task.Status != "failed" || task.Stage != "service_restart" || task.FinishedAt == nil {
|
||||
t.Fatalf("任务 %s 未按服务重启恢复:%+v", expectedID, task)
|
||||
}
|
||||
if task.Message != "服务重启导致任务中断,请重新执行" {
|
||||
if task.Message != "服务重启导致任务中断,执行结果未确认;请先核查关联资源,证书部署需检查远端文件、部署锁及服务状态,确认安全后再重试" {
|
||||
t.Fatalf("任务 %s 中断说明不正确:%s", expectedID, task.Message)
|
||||
}
|
||||
}
|
||||
|
||||
22
backend/internal/app/certificate_diagnostics_test.go
Normal file
22
backend/internal/app/certificate_diagnostics_test.go
Normal file
@ -0,0 +1,22 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
|
||||
)
|
||||
|
||||
func TestTaskDeploymentResultsPersistSafeFailureDiagnostics(t *testing.T) {
|
||||
report := deployment.Report{Results: []deployment.Result{
|
||||
{Status: "failed", FailureCode: deployment.FailureLock, Message: "secret-path"},
|
||||
{Status: "failed", FailureCode: "secret-code", Message: "secret-output"},
|
||||
{Status: "success", Message: "部署完成"},
|
||||
}}
|
||||
results := taskDeploymentResults(report)
|
||||
if len(results) != 3 || results[0].Message != deployment.PublicResult(report.Results[0]).Message || results[1].Message != "证书部署失败" || results[2].Message != "部署完成" {
|
||||
t.Fatalf("持久化部署诊断不正确:%+v", results)
|
||||
}
|
||||
if report.Results[0].Message != "secret-path" {
|
||||
t.Fatal("不得覆盖受保护的原始错误")
|
||||
}
|
||||
}
|
||||
@ -179,6 +179,7 @@ func taskDeploymentResults(report deployment.Report) []repository.TaskDeployment
|
||||
}
|
||||
items := make([]repository.TaskDeploymentResult, 0, len(report.Results))
|
||||
for index, item := range report.Results {
|
||||
item = deployment.PublicResult(item)
|
||||
items = append(items, repository.TaskDeploymentResult{
|
||||
ResultIndex: index,
|
||||
ServerID: item.ServerID,
|
||||
|
||||
61
backend/internal/deployment/diagnostics.go
Normal file
61
backend/internal/deployment/diagnostics.go
Normal file
@ -0,0 +1,61 @@
|
||||
package deployment
|
||||
|
||||
type FailureCode string
|
||||
|
||||
const (
|
||||
FailureUnavailable FailureCode = "unavailable"
|
||||
FailureArtifact FailureCode = "artifact_invalid"
|
||||
FailureConfiguration FailureCode = "configuration_invalid"
|
||||
FailureConnection FailureCode = "connection_failed"
|
||||
FailureDirectory FailureCode = "directory_failed"
|
||||
FailureLock FailureCode = "lock_unavailable"
|
||||
FailureUpload FailureCode = "upload_failed"
|
||||
FailureReplace FailureCode = "replace_failed"
|
||||
FailureValidation FailureCode = "validation_failed"
|
||||
FailureRollback FailureCode = "rollback_failed"
|
||||
FailureRuntimeUnknown FailureCode = "runtime_unconfirmed"
|
||||
FailureCleanup FailureCode = "cleanup_failed"
|
||||
FailureLockRelease FailureCode = "lock_release_failed"
|
||||
)
|
||||
|
||||
// 只公开固定诊断,不从远端输出、错误文本或文件路径推断可展示的消息。
|
||||
var publicFailureMessages = map[FailureCode]string{
|
||||
FailureUnavailable: "部署服务不可用,请联系管理员检查服务配置",
|
||||
FailureArtifact: "待部署的证书或私钥校验失败,尚未推送到服务器",
|
||||
FailureConfiguration: "服务器部署配置无效,请检查认证、证书路径和命令配置",
|
||||
FailureConnection: "连接部署服务器失败,请检查网络、SSH 认证及主机身份配置",
|
||||
FailureDirectory: "无法创建证书目录,请检查部署账户的目录权限",
|
||||
FailureLock: "无法取得部署锁,可能有任务正在执行或存在中断遗留;请管理员核查远端文件、部署锁及服务状态后再重试,勿直接删除锁",
|
||||
FailureUpload: "证书上传失败,已尝试回滚;请检查受保护的服务日志后再重试",
|
||||
FailureReplace: "证书文件替换失败,已尝试回滚;请检查受保护的服务日志后再重试",
|
||||
FailureValidation: "服务配置校验失败,文件已回滚,未执行重载;请修正配置后再重试",
|
||||
FailureRollback: "部署失败且文件回滚未完成,已保留部署锁;请管理员核查远端文件及服务状态,勿直接重试或删除锁",
|
||||
FailureRuntimeUnknown: "服务重载失败,文件已回滚,但服务运行状态未确认;请管理员核对配置并恢复服务后再重试",
|
||||
FailureCleanup: "证书已推送并重载,但旧文件清理未完成;请管理员核查现场,不能据此确认站点新证书已生效",
|
||||
FailureLockRelease: "部署锁释放未完成;请管理员核查远端文件、部署锁及服务状态后再重试",
|
||||
}
|
||||
|
||||
// PublicResult 保留结果事实,同时只允许固定错误码及中文诊断进入公开响应。
|
||||
func PublicResult(result Result) Result {
|
||||
if result.Status != "failed" {
|
||||
result.FailureCode = ""
|
||||
return result
|
||||
}
|
||||
message, ok := publicFailureMessages[result.FailureCode]
|
||||
if !ok {
|
||||
result.FailureCode = ""
|
||||
message = "证书部署失败"
|
||||
}
|
||||
result.Message = message
|
||||
return result
|
||||
}
|
||||
|
||||
// PublicStoredFailureMessage 兼容历史任务;仅原样放行完整匹配的固定诊断。
|
||||
func PublicStoredFailureMessage(message string) string {
|
||||
for _, safe := range publicFailureMessages {
|
||||
if message == safe {
|
||||
return safe
|
||||
}
|
||||
}
|
||||
return "证书部署失败"
|
||||
}
|
||||
97
backend/internal/deployment/diagnostics_test.go
Normal file
97
backend/internal/deployment/diagnostics_test.go
Normal file
@ -0,0 +1,97 @@
|
||||
package deployment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type lockReleaseFailureClient struct {
|
||||
*memoryClient
|
||||
}
|
||||
|
||||
func (c *lockReleaseFailureClient) Unlock(context.Context, string) error {
|
||||
return errors.New("secret-path lock-release-error")
|
||||
}
|
||||
|
||||
func TestDeploymentLockReleaseFailureOverridesSuccess(t *testing.T) {
|
||||
client := &lockReleaseFailureClient{memoryClient: newMemoryClient()}
|
||||
server := testServer("unlock")
|
||||
report := NewService(fixedClientFactory{client}).Deploy(t.Context(), []Target{{Server: server}}, testArtifact(t))
|
||||
if report.Status != "failed" || report.Success != 0 || report.Failed != 1 || report.Results[0].FailureCode != FailureLockRelease {
|
||||
t.Fatalf("锁释放失败不得保留成功状态:%+v", report)
|
||||
}
|
||||
public := PublicResult(report.Results[0])
|
||||
if strings.Contains(public.Message, "secret-path") || !strings.Contains(public.Message, "部署锁释放未完成") {
|
||||
t.Fatalf("锁释放诊断错误:%+v", public)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicDeploymentDiagnosticsAreAllowlisted(t *testing.T) {
|
||||
for code, expected := range publicFailureMessages {
|
||||
t.Run(string(code), func(t *testing.T) {
|
||||
raw := Result{ServerID: "server-1", Status: "failed", FailureCode: code, Message: "secret-path private-key remote-output"}
|
||||
public := PublicResult(raw)
|
||||
if public.Message != expected || public.FailureCode != code || public.ServerID != raw.ServerID {
|
||||
t.Fatalf("固定诊断或结果身份丢失:%+v", public)
|
||||
}
|
||||
if PublicStoredFailureMessage(public.Message) != expected {
|
||||
t.Fatal("持久化诊断无法读取")
|
||||
}
|
||||
if PublicStoredFailureMessage(public.Message+" secret-path") != "证书部署失败" {
|
||||
t.Fatal("包含额外敏感内容的诊断不应放行")
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, code := range []FailureCode{"", "secret-path", "lock_unavailable\nprivate-key"} {
|
||||
public := PublicResult(Result{Status: "failed", FailureCode: code, Message: "无法取得部署锁 /secret-path"})
|
||||
if public.FailureCode != "" || public.Message != "证书部署失败" {
|
||||
t.Fatalf("未知错误码或原始错误泄露:%+v", public)
|
||||
}
|
||||
}
|
||||
public := PublicResult(Result{Status: "success", FailureCode: "secret-path", Message: "部署完成"})
|
||||
if public.FailureCode != "" || public.Message != "部署完成" {
|
||||
t.Fatalf("成功结果错误:%+v", public)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeploymentFailureCodesDescribeActualRecoveryBoundary(t *testing.T) {
|
||||
for _, scenario := range []struct {
|
||||
name string
|
||||
code FailureCode
|
||||
configure func(*memoryClient, Server)
|
||||
}{
|
||||
{"lock", FailureLock, func(c *memoryClient, s Server) { c.files[s.PrivateKeyPath+".scert-lock"] = memoryFile{} }},
|
||||
{"upload", FailureUpload, func(c *memoryClient, _ Server) { c.uploadError = ".scert-tmp-" }},
|
||||
{"replace", FailureReplace, func(c *memoryClient, _ Server) { c.renameError = ".scert-tmp-" }},
|
||||
{"validation", FailureValidation, func(c *memoryClient, _ Server) { c.runErrors = []error{errors.New("secret-path")} }},
|
||||
{"reload", FailureRuntimeUnknown, func(c *memoryClient, _ Server) { c.runErrors = []error{nil, errors.New("secret-path")} }},
|
||||
{"validation_rollback", FailureRollback, func(c *memoryClient, _ Server) {
|
||||
c.runErrors = []error{errors.New("secret-path")}
|
||||
c.restoreError = true
|
||||
}},
|
||||
{"reload_rollback", FailureRollback, func(c *memoryClient, _ Server) {
|
||||
c.runErrors = []error{nil, errors.New("secret-path")}
|
||||
c.restoreError = true
|
||||
}},
|
||||
{"cleanup", FailureCleanup, func(c *memoryClient, _ Server) { c.removeError = ".scert-backup-" }},
|
||||
} {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
client := newMemoryClient()
|
||||
server := testServer("diagnostics")
|
||||
for _, path := range []string{server.CertificatePath, server.CertificateChainPath, server.PrivateKeyPath} {
|
||||
client.files[path] = memoryFile{data: []byte("original"), mode: 0o600}
|
||||
}
|
||||
scenario.configure(client, server)
|
||||
report := NewService(fixedClientFactory{client}).Deploy(t.Context(), []Target{{Server: server}}, testArtifact(t))
|
||||
if report.Status != "failed" || len(report.Results) != 1 || report.Results[0].FailureCode != scenario.code {
|
||||
t.Fatalf("失败阶段或恢复边界错误:%+v", report)
|
||||
}
|
||||
public := PublicResult(report.Results[0])
|
||||
if strings.Contains(public.Message, "secret-path") || public.Message == "证书部署失败" {
|
||||
t.Fatalf("阶段诊断缺失或泄露底层信息:%+v", public)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -29,11 +29,12 @@ type SavedFiles struct {
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
ServerID string `json:"server_id"`
|
||||
Server string `json:"server"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
BackupID string `json:"backup_id,omitempty"`
|
||||
ServerID string `json:"server_id"`
|
||||
Server string `json:"server"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
BackupID string `json:"backup_id,omitempty"`
|
||||
FailureCode FailureCode `json:"failure_code,omitempty"`
|
||||
}
|
||||
|
||||
const deploymentRollbackTimeout = 30 * time.Second
|
||||
@ -67,7 +68,7 @@ func (s *Service) Deploy(ctx context.Context, targets []Target, artifact Artifac
|
||||
report.Status = "failed"
|
||||
report.Failed = len(targets)
|
||||
for _, target := range targets {
|
||||
report.Results = append(report.Results, Result{ServerID: target.Server.ID, Server: target.Server.Name, Status: "failed", Message: "部署服务未初始化"})
|
||||
report.Results = append(report.Results, Result{ServerID: target.Server.ID, Server: target.Server.Name, Status: "failed", Message: "部署服务未初始化", FailureCode: FailureUnavailable})
|
||||
}
|
||||
return report
|
||||
}
|
||||
@ -75,7 +76,7 @@ func (s *Service) Deploy(ctx context.Context, targets []Target, artifact Artifac
|
||||
report.Status = "failed"
|
||||
report.Failed = len(targets)
|
||||
for _, target := range targets {
|
||||
report.Results = append(report.Results, Result{ServerID: target.Server.ID, Server: target.Server.Name, Status: "failed", Message: err.Error()})
|
||||
report.Results = append(report.Results, Result{ServerID: target.Server.ID, Server: target.Server.Name, Status: "failed", Message: err.Error(), FailureCode: FailureArtifact})
|
||||
}
|
||||
return report
|
||||
}
|
||||
@ -119,13 +120,14 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
defer cancel()
|
||||
server = server.Normalize()
|
||||
result = Result{ServerID: server.ID, Server: server.Name, Status: "failed"}
|
||||
result = Result{ServerID: server.ID, Server: server.Name, Status: "failed", FailureCode: FailureConfiguration}
|
||||
if err := server.Validate(); err != nil {
|
||||
result.Message = err.Error()
|
||||
return result
|
||||
}
|
||||
client, err := s.factory.Connect(ctx, server)
|
||||
if err != nil {
|
||||
result.FailureCode = FailureConnection
|
||||
result.Message = fmt.Sprintf("连接服务器失败:%v", err)
|
||||
return result
|
||||
}
|
||||
@ -149,6 +151,7 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac
|
||||
lockPaths := make([]string, 0, len(paths))
|
||||
for _, file := range paths {
|
||||
if err := client.MkdirAll(ctx, parentPath(file.path)); err != nil {
|
||||
result.FailureCode = FailureDirectory
|
||||
result.Message = fmt.Sprintf("创建远程目录失败:%v", err)
|
||||
return result
|
||||
}
|
||||
@ -165,18 +168,21 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac
|
||||
defer cancel()
|
||||
if err := reconnectDeploymentClient(releaseCtx, s.factory, server, &client); err != nil {
|
||||
result.Status = "failed"
|
||||
result.FailureCode = FailureLockRelease
|
||||
result.Message += fmt.Sprintf(";释放部署锁失败,需人工检查:%v", err)
|
||||
return
|
||||
}
|
||||
for index := len(heldLocks) - 1; index >= 0; index-- {
|
||||
if err := client.Unlock(releaseCtx, heldLocks[index]); err != nil {
|
||||
result.Status = "failed"
|
||||
result.FailureCode = FailureLockRelease
|
||||
result.Message += fmt.Sprintf(";释放部署锁 %s 失败,需人工检查:%v", heldLocks[index], err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
for _, lockPath := range lockPaths {
|
||||
if err := client.Lock(ctx, lockPath); err != nil {
|
||||
result.FailureCode = FailureLock
|
||||
result.Message = fmt.Sprintf("无法取得部署锁 %s,可能存在并发部署或遗留锁;未修改当前证书,请人工核查:%v", lockPath, err)
|
||||
return result
|
||||
}
|
||||
@ -224,6 +230,7 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac
|
||||
reloadAttempted := false
|
||||
failed := func(message string) Result {
|
||||
if rollbackErr := rollback(); rollbackErr != nil {
|
||||
result.FailureCode = FailureRollback
|
||||
message += fmt.Sprintf(";自动回滚失败:%v", rollbackErr)
|
||||
retainLocks = true
|
||||
message += ";已保留本次部署锁,修复并确认文件状态后再由管理员释放"
|
||||
@ -231,12 +238,16 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac
|
||||
message += ";文件已回滚"
|
||||
}
|
||||
if reloadAttempted {
|
||||
if !retainLocks {
|
||||
result.FailureCode = FailureRuntimeUnknown
|
||||
}
|
||||
// 命令可能已部分执行;文件回滚并不证明运行中的服务已经恢复。
|
||||
message += ";服务运行状态未确认,请检查服务并在确认旧配置后重新加载"
|
||||
}
|
||||
result.Message = message
|
||||
return result
|
||||
}
|
||||
result.FailureCode = FailureUpload
|
||||
for _, file := range paths {
|
||||
temporaryPath := file.path + ".scert-tmp-" + backupID
|
||||
// 上传失败也可能留下半份私钥,必须在开始上传前登记清理路径。
|
||||
@ -251,6 +262,7 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac
|
||||
}
|
||||
}
|
||||
// 所有文件上传完成后才触碰当前版本,避免上传失败造成不必要的证书替换。
|
||||
result.FailureCode = FailureReplace
|
||||
for _, file := range paths {
|
||||
exists, err := client.Exists(ctx, file.path)
|
||||
if err != nil {
|
||||
@ -272,6 +284,7 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac
|
||||
return failed(fmt.Sprintf("替换远程证书失败:%v", err))
|
||||
}
|
||||
}
|
||||
result.FailureCode = FailureValidation
|
||||
validationResult, err := client.Run(ctx, server.ValidationCommand)
|
||||
if err != nil || validationResult.ExitCode != 0 {
|
||||
if err == nil {
|
||||
@ -289,10 +302,12 @@ 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)
|
||||
return result
|
||||
}
|
||||
result.Status = "success"
|
||||
result.FailureCode = ""
|
||||
result.Message = "证书部署、配置校验并 reload 成功"
|
||||
result.BackupID = backupID
|
||||
return result
|
||||
|
||||
@ -26,9 +26,7 @@ func sanitizeDeploymentReport(report deployment.Report) deployment.Report {
|
||||
result := report
|
||||
result.Results = append([]deployment.Result(nil), report.Results...)
|
||||
for index := range result.Results {
|
||||
if result.Results[index].Status == "failed" {
|
||||
result.Results[index].Message = "证书部署失败"
|
||||
}
|
||||
result.Results[index] = deployment.PublicResult(result.Results[index])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
28
backend/internal/httpapi/error_sanitization_test.go
Normal file
28
backend/internal/httpapi/error_sanitization_test.go
Normal file
@ -0,0 +1,28 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
|
||||
)
|
||||
|
||||
func TestSanitizeDeploymentReportKeepsSafeDiagnosisWithoutMutatingEvidence(t *testing.T) {
|
||||
report := deployment.Report{Status: "failed", Results: []deployment.Result{
|
||||
{Status: "failed", FailureCode: deployment.FailureLock, Message: "secret-path"},
|
||||
{Status: "failed", FailureCode: "secret-code", Message: "secret-output"},
|
||||
}}
|
||||
public := sanitizeDeploymentReport(report)
|
||||
if public.Results[0].FailureCode != deployment.FailureLock || public.Results[0].Message != deployment.PublicResult(report.Results[0]).Message {
|
||||
t.Fatalf("固定锁诊断丢失:%+v", public)
|
||||
}
|
||||
if public.Results[1].FailureCode != "" || public.Results[1].Message != "证书部署失败" {
|
||||
t.Fatalf("未知诊断未脱敏:%+v", public)
|
||||
}
|
||||
if report.Results[0].Message != "secret-path" || report.Results[1].FailureCode != "secret-code" {
|
||||
t.Fatal("公开响应脱敏修改了受保护的原始证据")
|
||||
}
|
||||
stored := taskDeploymentResults(report)
|
||||
if stored[0].Message != public.Results[0].Message || stored[1].Message != "证书部署失败" {
|
||||
t.Fatalf("接口工作流未持久化安全诊断:%+v", stored)
|
||||
}
|
||||
}
|
||||
@ -553,7 +553,7 @@ func (s *Server) listTaskDeploymentResults(c *gin.Context) {
|
||||
}
|
||||
for index := range items {
|
||||
if items[index].Status == "failed" {
|
||||
items[index].Message = "证书部署失败"
|
||||
items[index].Message = deployment.PublicStoredFailureMessage(items[index].Message)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
@ -565,6 +565,7 @@ func taskDeploymentResults(report deployment.Report) []repository.TaskDeployment
|
||||
}
|
||||
items := make([]repository.TaskDeploymentResult, 0, len(report.Results))
|
||||
for index, item := range report.Results {
|
||||
item = deployment.PublicResult(item)
|
||||
items = append(items, repository.TaskDeploymentResult{
|
||||
ResultIndex: index,
|
||||
ServerID: item.ServerID,
|
||||
|
||||
@ -78,6 +78,34 @@ func TestTaskDeploymentResultsIssueAndRead(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskDeploymentResultsKeepAllowlistedLockDiagnosis(t *testing.T) {
|
||||
raw := deployment.Result{ServerID: "server-a", Status: "failed", FailureCode: deployment.FailureLock, Message: "secret-path remote-command-output"}
|
||||
server, tasks, _, closeDB := newTaskAPITestServer(t, func(_ context.Context, id string) (certificate.IssueResult, deployment.Report, error) {
|
||||
return certificate.IssueResult{DomainID: id}, deployment.Report{Status: "failed", Total: 1, Failed: 1, Results: []deployment.Result{raw}}, errors.New("部署失败")
|
||||
})
|
||||
defer closeDB()
|
||||
response := httptest.NewRecorder()
|
||||
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil))
|
||||
if response.Code != http.StatusBadGateway || strings.Contains(response.Body.String(), "secret-path") || !strings.Contains(response.Body.String(), "lock_unavailable") {
|
||||
t.Fatalf("部署接口缺少安全的锁诊断:%d %s", response.Code, response.Body.String())
|
||||
}
|
||||
items, err := tasks.ListTasks(t.Context(), "", 10)
|
||||
if err != nil || len(items) != 1 {
|
||||
t.Fatalf("任务未保存:%+v %v", items, err)
|
||||
}
|
||||
response = httptest.NewRecorder()
|
||||
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodGet, "/api/v1/tasks/"+items[0].ID+"/deployment-results", nil))
|
||||
var payload struct {
|
||||
Items []repository.TaskDeploymentResult `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.Code != http.StatusOK || len(payload.Items) != 1 || payload.Items[0].Message != deployment.PublicResult(raw).Message {
|
||||
t.Fatalf("任务查询丢失固定锁诊断:%d %s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskDeploymentResultsHTTPSFailurePreservesTransferEvidence(t *testing.T) {
|
||||
server, tasks, _, closeDB := newTaskAPITestServer(t, func(_ context.Context, id string) (certificate.IssueResult, deployment.Report, error) {
|
||||
return certificate.IssueResult{DomainID: id}, deployment.Report{
|
||||
|
||||
@ -0,0 +1,39 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTaskRecoveryRequiresResourceInspectionBeforeRetry(t *testing.T) {
|
||||
repo, db := newTaskRepositoryTest(t)
|
||||
defer db.Close()
|
||||
ctx := t.Context()
|
||||
if err := repo.CreateTask(ctx, Task{ID: "interrupted-deploy", DomainID: "domain-1", TaskType: "certificate_deploy", Status: "running", Stage: "certificate_deploy"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.CreateAttempt(ctx, TaskAttempt{ID: "interrupted-attempt", TaskID: "interrupted-deploy", AttemptNumber: 1, Stage: "certificate_deploy", Status: "running"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count, err := repo.RecoverInterruptedTasks(ctx, time.Now()); err != nil || count != 1 {
|
||||
t.Fatalf("恢复任务失败:%d %v", count, err)
|
||||
}
|
||||
task, err := repo.GetTask(ctx, "interrupted-deploy")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, expected := range []string{"执行结果未确认", "远端文件", "部署锁", "服务状态", "确认安全后再重试"} {
|
||||
if !strings.Contains(task.Message, expected) {
|
||||
t.Errorf("中断任务缺少恢复边界提示 %q:%s", expected, task.Message)
|
||||
}
|
||||
}
|
||||
attempts, err := repo.ListAttempts(ctx, task.ID)
|
||||
if err != nil || len(attempts) != 1 || attempts[0].Message != task.Message {
|
||||
t.Fatalf("执行尝试与任务恢复说明不一致:%+v %v", attempts, err)
|
||||
}
|
||||
var auditMessage string
|
||||
if err := db.QueryRowContext(ctx, `SELECT message FROM audit_logs WHERE action = 'task_recovery' AND task_id = ?`, task.ID).Scan(&auditMessage); err != nil || auditMessage != task.Message {
|
||||
t.Fatalf("恢复审计说明不一致:%s %v", auditMessage, err)
|
||||
}
|
||||
}
|
||||
@ -346,7 +346,7 @@ func (r *SQLTaskRepository) RecoverInterruptedTasks(ctx context.Context, finishe
|
||||
rollback := func() {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
recoveryMessage := "\u670d\u52a1\u91cd\u542f\u5bfc\u81f4\u4efb\u52a1\u4e2d\u65ad\uff0c\u8bf7\u91cd\u65b0\u6267\u884c"
|
||||
recoveryMessage := "服务重启导致任务中断,执行结果未确认;请先核查关联资源,证书部署需检查远端文件、部署锁及服务状态,确认安全后再重试"
|
||||
auditResult, err := tx.ExecContext(ctx, r.query(`INSERT INTO audit_logs
|
||||
(action, resource_type, resource_id, status, message, created_at, scope_type, project_id, task_id, operator_id)
|
||||
SELECT 'task_recovery', 'task', id, 'failed', ?, ?, scope_type, project_id, id, operator_id
|
||||
|
||||
@ -1,4 +1,17 @@
|
||||
# Linux 生产流程测试记录
|
||||
## 真实部署中断与人工恢复(2026-09-30 22:35)
|
||||
|
||||
- 候选仍为 91e1123。沿用已有证书和数据执行破坏性中断验证,没有清库,不能替代最终首次生产流程;未新增辅助验收脚本。
|
||||
- 通过正式服务器配置接口临时设定校验命令为有界延迟后执行 nginx 配置校验,重载命令保持正常。22:25:00 从正式页面触发第二域名部署,任务 8aa3db95-ab7a-4559-a558-a1a9c8fa5f00、尝试 656a9679-33ab-458e-bcbe-2ed6694735eb 均为 running,远端可见 sleep 45 进程及三个部署锁和对应事务旧文件。
|
||||
- 首次 systemctl 命令使用当前系统不支持的参数,未终止进程;确认任务与延迟进程仍活动后改用该系统支持的 --kill-who=main。22:25:30 SIGKILL 主 PID 81906,22:25:31 systemd 自动启动 PID 83070,NRestarts=1。不是空闲服务重启,也没有将首条失败命令记为成功。
|
||||
- 任务恢复为 failed/service_restart,尝试为 failed,task_recovery 审计为 failed;三者旧提示均为“服务重启导致任务中断,请重新执行”。无自动重新签发或重放远端命令。
|
||||
- 22:26:42 重新登录后通过产品接口恢复 nginx -t 与 nginx -s reload。延迟进程已结束;远端保留三个锁和三个事务旧文件,nginx worker 未变,新旧 cert/fullchain/key 逐一内容一致。
|
||||
- 22:27:09 产品直接重试,任务 0d6dfdf0-65a0-4af5-8544-0f9c3b604018 失败,接口 502,没有误报成功;原有脱敏只显示“证书部署失败”,不能告知用户遗留锁原因。
|
||||
- 确认活动任务为零、延迟进程结束、目录真实路径正确、三个锁为空且非符号链接、新旧文件一致后,将六个现场项目移到 /ssl/scert-interruption-evidence/8aa3db95-ab7a-4559-a558-a1a9c8fa5f00。受限目录权限 700,私钥旧文件 600;未删除文件、未自动抢占未知锁,也未覆盖另一域名文件。
|
||||
- 22:28:14 正式产品接口部署已有证书,任务 800815bd-7fcd-4262-87c0-63fd6174dd29 成功,返回 verification_status=success。22:33 再查主服务、目录 socket、nginx 均 active,活动任务为零,目标目录无残留锁;两域名正常 TLS HTTPS 200,公网指纹各自保持与托管证书一致。
|
||||
- 结论:启动任务恢复、遗留锁拒绝和人工核查后重新部署取得证据;远端自动回滚及自动解除崩溃事务未实现。已开发固定阶段安全诊断及中断核查提示,尚未部署,不能将本次人工恢复记为自动恢复通过。
|
||||
- 22:41 开发回归完成:后端全量测试及静态检查退出零,部署、仓储、应用、接口四包竞态通过,前端 240 项通过;开始干净源码正式构建,构建通过不替代实机复测。
|
||||
|
||||
## 单台服务器双域名闭环(2026-09-30 22:18)
|
||||
|
||||
- 新域名 scert-multi-20260930.apmmk.cn,资源 ID fd0b6b70-9cca-48d7-9d9f-40551cb95fc5,与第一域名关联同一服务器 3cfe6b82-6807-421d-88ab-a2034370375b。
|
||||
|
||||
@ -1,4 +1,19 @@
|
||||
# Scert 整改任务执行清单
|
||||
## 当前任务看板(2026-09-30 22:35,中断恢复实测与诊断整改)
|
||||
|
||||
需求确认:沿用完整 P0-10 与生产验收范围。修改前检查点 b27eedc 已推送;本轮验证部署中断、遗留锁、人工核查后的恢复,并修正公开诊断,不把诊断改善当作远端事务自动恢复。
|
||||
|
||||
- [x] 真实部署执行中 SIGKILL 主进程,systemd 自动重启;任务、尝试恢复为 failed,阶段 service_restart,审计写入,没有悬挂或误报成功。
|
||||
- [x] 实测直接重试被遗留锁拒绝;核实无活动任务、远程延迟进程结束及新旧文件一致后,将三个锁和三个事务旧文件移入受限证据目录,未删除现场;产品重新部署成功,两站点正常。
|
||||
- [x] 新增固定阶段错误码和中文安全诊断,覆盖连接、目录、锁、上传、替换、校验、回滚、运行态未确认及清理;接口和持久化结果只放行固定诊断,保留底层受保护证据。
|
||||
- [x] 中断任务提示不再要求直接重试,明确执行结果未确认及关联资源核查;正式回归修复前失败、修复后通过。部署、仓储、应用和接口四包测试通过。
|
||||
- [x] 接口读取与前端诊断回归通过;后端全量测试、静态检查、部署/仓储/应用/接口四包竞态均退出零,前端 240 项通过。
|
||||
- [~] 提交当前修复,使用干净源码正式构建及部署复测。
|
||||
- [!] 当前线上仍为 91e1123;新诊断尚未部署。远端崩溃事务没有自动恢复,P0-10 整项未完成。
|
||||
- [ ] 第二台真实服务器、三数据库矩阵、Linux 原生与 Windows 服务、备份恢复和最终清库首次生产流程继续保留。
|
||||
|
||||
以下带时间章节为历史证据,当前状态以上节为准。
|
||||
|
||||
## 当前任务看板(2026-09-30 22:18)
|
||||
|
||||
需求确认:沿用已确认完整整改范围;新包正式安装、DNS 复测、旧证书负向与恢复、单机多域名依次推进,不缩减多服务器、数据库和最终首次流程验收。修改前检查点 7d0ce94 已推送。下方各时间章节保留为历史证据,当前状态以本节为准。
|
||||
|
||||
@ -1,4 +1,15 @@
|
||||
# Scert 整改、合并与优化清单
|
||||
## 当前验收总览(2026-09-30 22:35,中断恢复边界已实测)
|
||||
|
||||
- [✓] P0-03 局部复审结论保持,不把后续事务门禁重新挂到源码目录任务下。
|
||||
- [x] 主进程在真实部署的远端配置校验阶段被强制终止,systemd 自动重启;任务、尝试和恢复审计一致为失败,没有永久执行中或误报成功。
|
||||
- [x] 已实证遗留三个部署锁阻止直接重试;人工核查新旧文件一致并隔离保留事务现场后,正式产品接口重新部署成功,两域名正常 HTTPS。此为人工恢复,不是自动事务恢复。
|
||||
- [x] 新增固定阶段中文诊断及错误码,任务查询兼容历史错误脱敏;中断提示改为先核查关联资源、远端文件、锁及服务状态。22:41 后端全量、静态检查、四包竞态及前端 240 项均通过。
|
||||
- [~] P0-10 继续进行:诊断代码尚未正式构建部署;进程崩溃后的远端自动恢复、后置 HTTPS 失败的运行态恢复均未完成。
|
||||
- [!] P0-04、P0-11、P0-12 等完整范围不变;第二台真实服务器、数据库矩阵、双平台服务与最终清库首次流程没有新增通过证据。不承诺当晚生产放行。
|
||||
|
||||
以下较早章节保留历史时间,不代表当前阻塞位置。
|
||||
|
||||
## 当前验收总览(2026-09-30 22:18,以本节为准)
|
||||
|
||||
下方带时间的进度为历史证据,不代表当前阻塞位置。P0-03 已按源码与产物边界范围复审通过,不再使用历史“仍停在 P0-03”作为当前结论。完整整改及生产验收仍未完成,不承诺当晚放行。
|
||||
@ -800,6 +811,8 @@ updated_by
|
||||
|
||||
**状态:** `[~] 进行中`
|
||||
|
||||
**中断恢复实测(2026-09-30 22:35):** 真实部署校验中强制终止主进程,systemd 自动重启,任务、尝试及审计正确标记中断失败;远端锁和事务旧文件保留,直接重试被拒绝。人工核查并隔离现场后可通过产品重新部署。已开发固定阶段诊断和先核查后重试提示,专项通过但未部署;远端崩溃事务自动恢复仍未完成,不能标记整项通过。
|
||||
|
||||
**最新整改(2026-09-30):** 已复现并修复后置 HTTPS 校验接受旧有效证书、校验失败仍返回成功部署报告的问题;身份来自实际待部署文件。报告以 verification_status 单独记录站点生效校验,整体失败不改写单台真实文件推送事实;任务、尝试和接口保持失败,页面不再汇总为完成。本批本地测试不等于实机恢复通过;该校验仍位于文件部署事务之后,不具备据此宣称自动回滚的依据。
|
||||
|
||||
所有长耗时操作统一进入任务中心:
|
||||
|
||||
@ -1,5 +1,24 @@
|
||||
# Scert 生产部署与运维说明
|
||||
|
||||
## 部署中断处理与阶段诊断(2026-09-30)
|
||||
|
||||
服务重启会将未完成任务和执行尝试标记为失败并记录恢复审计,但这不表示远端文件、部署锁或服务运行态已经恢复。不得仅凭任务失败提示直接重复申请证书或删除远端锁。
|
||||
|
||||
处理前应确认没有活动部署和仍在执行的远程命令,核对任务关联服务器、三个目标文件、对应事务旧文件、权限及实际站点证书。只有确认现场归属和恢复方案后才能由管理员处置;保留证据,不抢占无法确认归属的锁。修正配置后优先部署已有证书,避免重复签发。
|
||||
|
||||
新候选的部署响应结果增加可选 failure_code,message 为固定中文诊断;任务部署结果查询保留同一安全说明,不要求数据库结构升级。旧记录和未知错误仍返回通用提示。代码、路径和远端输出不从错误文本直接透传;详细原因查受保护日志。新增诊断尚需正式部署复测,本节不代表自动恢复功能已完成。
|
||||
|
||||
| 错误码 | 处理含义 |
|
||||
| --- | --- |
|
||||
| lock_unavailable | 并发部署、遗留锁或锁创建失败;先核查,不直接删除锁 |
|
||||
| validation_failed | 配置校验失败,文件已回滚,未执行重载 |
|
||||
| rollback_failed | 文件回滚未完成,保留锁并人工核查 |
|
||||
| runtime_unconfirmed | 重载失败后文件回滚,但运行态未确认 |
|
||||
| lock_release_failed | 锁释放失败,核查现场后再重试 |
|
||||
| cleanup_failed | 已推送并重载,但旧文件清理失败,不能宣称站点已生效 |
|
||||
|
||||
连接、目录、上传和文件替换等阶段也有独立固定诊断。站点 HTTPS 校验继续使用 verification_status,与单台服务器文件推送结果分开判断。
|
||||
|
||||
> 验证收尾同步(2026-09-28):最终候选已完成 Windows 独立程序历史备份/升级/成员恢复和 Windows/Linux 构建,产物指纹与最终回归见支持矩阵第二十二节。Linux 为交叉构建,未进行原生运行;本批没有清理生产数据库、执行 SSH 部署或真实服务重启,仍禁止将候选构建通过解释为生产准入。
|
||||
|
||||
> 当前运行时候选(2026-09-28):SQLite 驱动固定为 v1.59.0,配套 libc v1.75.7,实测引擎 3.53.4;模式仍为 14。旧 v1.40.1 的专项通过不意味着其引擎满足当前 WAL 数据完整性要求,不使用中间版本替代最终候选。升级需先验证正式备份、历史模式升级及恢复;不直接部署本批工作区或降低 TLS。当前验证与未关闭的 MySQL 索引异常见支持矩阵第二十二节,本节不是生产放行许可。
|
||||
|
||||
@ -100,6 +100,28 @@ test("HTTPS 校验失败响应保留推送明细,但不弹出成功通知", as
|
||||
assert.equal(state.notice.value, "");
|
||||
});
|
||||
|
||||
test("遗留锁的安全诊断保留在失败明细中,不提示部署成功", async (t) => {
|
||||
const message = "无法取得部署锁,可能有任务正在执行或存在中断遗留;请管理员核查远端文件、部署锁及服务状态后再重试,勿直接删除锁";
|
||||
const failed = {
|
||||
total: 1,
|
||||
success: 0,
|
||||
failed: 1,
|
||||
status: "failed",
|
||||
results: [{ server_id: "server-1", server: "测试服务器", status: "failed", failure_code: "lock_unavailable", message }],
|
||||
};
|
||||
const { state } = await mountComposable(t, useDashboard, async (path) => {
|
||||
if (path.endsWith("/deploy")) {
|
||||
return Response.json({ error: "证书部署失败", deployment: failed }, { status: 502 });
|
||||
}
|
||||
return Response.json({ items: [] });
|
||||
});
|
||||
await state.deployDomain({ id: "domain-1", name: "example.test" });
|
||||
assert.equal(state.deploymentResult.value.report.results[0].message, message);
|
||||
assert.equal(state.deploymentResult.value.report.results[0].failure_code, "lock_unavailable");
|
||||
assert.equal(state.notice.value, "");
|
||||
assert.equal(state.deploymentResult.value.running, false);
|
||||
});
|
||||
|
||||
test("部署结果组件可编译且认证提示绑定真实配置", async () => {
|
||||
const source = await readFile(new URL("./DeploymentResult.vue", import.meta.url), "utf8");
|
||||
const { descriptor, errors } = parse(source);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user