保存完整任务报告及完成审计原子化基线,最新回归待确认

This commit is contained in:
xuans 2026-10-02 17:51:37 +08:00
parent 8a8b1a5e0e
commit 4e8d50919a
21 changed files with 505 additions and 52 deletions

View File

@ -20,6 +20,7 @@ import (
//go:embed migrations/sqlite/0016_task_deployment_results.sql migrations/mysql/0016_task_deployment_results.sql migrations/postgres/0016_task_deployment_results.sql
//go:embed migrations/sqlite/0017_deployment_transactions.sql migrations/mysql/0017_deployment_transactions.sql migrations/postgres/0017_deployment_transactions.sql
//go:embed migrations/sqlite/0018_recovery_evidence.sql migrations/mysql/0018_recovery_evidence.sql migrations/postgres/0018_recovery_evidence.sql migrations/sqlite/0019_password_auth_only.sql migrations/mysql/0019_password_auth_only.sql migrations/postgres/0019_password_auth_only.sql
//go:embed migrations/sqlite/0020_task_deployment_reports.sql migrations/mysql/0020_task_deployment_reports.sql migrations/postgres/0020_task_deployment_reports.sql
var migrationFiles embed.FS
func Migrate(ctx context.Context, db *sql.DB, driver string) error {
@ -44,7 +45,7 @@ func Migrate(ctx context.Context, db *sql.DB, driver string) error {
return nil
}
const latestMigrationVersion = 19
const latestMigrationVersion = 20
// currentMigrationVersion 在执行业务迁移前拒绝新版数据库及不连续的迁移历史。
func currentMigrationVersion(ctx context.Context, db *sql.DB) (int, error) {
@ -84,7 +85,7 @@ func applyMigration(ctx context.Context, db *sql.DB, driver string, version int)
return applySQLiteTaskMigration(ctx, db, statements, version)
}
if driver == "mysql" {
if version >= 15 && version <= 17 {
if (version >= 15 && version <= 17) || version == 20 {
return applyMySQLDeploymentMigration(ctx, db, version, statements)
}
if version == 14 {
@ -237,6 +238,8 @@ func migrationName(version int) string {
return "recovery_evidence"
case 19:
return "password_auth_only"
case 20:
return "task_deployment_reports"
default:
return "unknown"
}

View File

@ -0,0 +1,8 @@
CREATE TABLE task_deployment_reports (
task_id VARCHAR(36) NOT NULL,
attempt_id VARCHAR(36) NOT NULL,
attempt_number INT NOT NULL,
report_json MEDIUMTEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (task_id, attempt_id)
);

View File

@ -0,0 +1,8 @@
CREATE TABLE task_deployment_reports (
task_id TEXT NOT NULL,
attempt_id TEXT NOT NULL,
attempt_number INTEGER NOT NULL,
report_json TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (task_id, attempt_id)
);

View File

@ -0,0 +1,8 @@
CREATE TABLE task_deployment_reports (
task_id TEXT NOT NULL,
attempt_id TEXT NOT NULL,
attempt_number INTEGER NOT NULL,
report_json TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (task_id, attempt_id)
);

View File

@ -57,7 +57,7 @@ type mysqlDeploymentShape struct {
var (
mysqlDeploymentTablePattern = regexp.MustCompile(`^CREATE TABLE ([a-z_]+) \($`)
mysqlDeploymentColumnPattern = regexp.MustCompile(`^([a-z_]+) (VARCHAR\([0-9]+\)|INT|TEXT|DATETIME) NOT NULL(?: DEFAULT (CURRENT_TIMESTAMP|0))?( PRIMARY KEY)?$`)
mysqlDeploymentColumnPattern = regexp.MustCompile(`^([a-z_]+) (VARCHAR\([0-9]+\)|INT|TEXT|MEDIUMTEXT|DATETIME) NOT NULL(?: DEFAULT (CURRENT_TIMESTAMP|0))?( PRIMARY KEY)?$`)
mysqlDeploymentIndexPattern = regexp.MustCompile(`^(PRIMARY KEY|UNIQUE KEY ([a-z_]+)|INDEX ([a-z_]+)) \(([a-z_, ]+)\)$`)
mysqlIntegerWidthPattern = regexp.MustCompile(`^int\([0-9]+\)$`)
)

View File

@ -8,7 +8,7 @@ import (
)
func TestMySQLDeploymentShapeDefinitions(t *testing.T) {
for version := 15; version <= 17; version++ {
for _, version := range []int{15, 16, 17, 20} {
statements := mysqlDeploymentStatements(t, version)
shape, err := parseMySQLDeploymentShape(statements[0])
if err != nil || len(shape.Columns) == 0 || len(shape.Indexes) == 0 {

View File

@ -60,6 +60,10 @@ func TestSQLiteCanceledQueriesReleaseFiles(t *testing.T) {
rows, err := query(ctx)
if rows != nil {
for rows.Next() {
// 单执行线程也要覆盖结果集已打开后的取消,保留其余轮次的自然竞争。
if attempt%2 == 0 {
runtime.Gosched()
}
var value int
if scanErr := rows.Scan(&value); scanErr != nil {
err = scanErr

View File

@ -180,15 +180,6 @@ func (s *Server) runCertificateTask(ctx context.Context, task repository.Task, a
}
attempt.Stage = stage
s.finishTaskBeforeExecution(task, attempt, "failed", failureMessage)
if s.audit != nil {
auditCtx, auditCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer auditCancel()
_ = s.audit.CreateAuditLog(auditCtx, repository.AuditLog{
TaskID: task.ID, OperatorID: task.OperatorID,
Action: auditAction, ResourceType: "domain", ResourceID: task.DomainID,
Status: "error", Message: failureMessage,
})
}
return
}
if s.audit != nil {
@ -216,10 +207,6 @@ func (s *Server) runCertificateTask(ctx context.Context, task repository.Task, a
finishCtx, finishCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer finishCancel()
deploymentReport = sanitizeDeploymentReport(deploymentReport)
recordErr := s.tasks.RecordTaskDeploymentResults(finishCtx, task.ID, attempt.ID, attempt.AttemptNumber, taskDeploymentResults(deploymentReport))
if recordErr != nil {
issueErr = errors.Join(issueErr, recordErr)
}
finished := time.Now().UTC()
status := "success"
message := successMessage
@ -240,9 +227,6 @@ func (s *Server) runCertificateTask(ctx context.Context, task repository.Task, a
} else if partial {
status = "partial_success"
}
if recordErr != nil {
status, message = "failed", "部署结果保存失败,请核查实际部署状态后再重试"
}
s.taskMu.Lock()
defer s.taskMu.Unlock()
if persisted, err := s.tasks.GetTask(finishCtx, task.ID); err != nil {
@ -257,21 +241,10 @@ func (s *Server) runCertificateTask(ctx context.Context, task repository.Task, a
message = "任务已停止;已执行操作是否回滚请查看部署结果"
}
}
if s.audit != nil {
auditStatus := status
if issueErr != nil && status != "cancelled" && status != "partial_success" {
auditStatus = "error"
}
if err := s.audit.CreateAuditLog(finishCtx, repository.AuditLog{
TaskID: task.ID, OperatorID: task.OperatorID,
Action: auditAction, ResourceType: "domain", ResourceID: task.DomainID, Status: auditStatus, Message: message,
}); err != nil {
status, message = "failed", "任务完成审计保存失败,请检查任务恢复状态"
}
}
if err := s.tasks.FinishTaskAttempt(finishCtx, repository.TaskCompletion{
TaskID: task.ID, AttemptID: attempt.ID, Status: status, Stage: stage,
Message: message, StartedAt: &now, FinishedAt: finished,
Report: &deploymentReport, AuditAction: auditAction,
}); err != nil && s.logger != nil {
s.logger.Error("保存任务及尝试终态失败,保留中断恢复状态", "task_id", task.ID, "error", err)
}
@ -620,7 +593,12 @@ func (s *Server) listTaskDeploymentResults(c *gin.Context) {
items[index].Message = deployment.PublicStoredFailureMessage(items[index].Message)
}
}
c.JSON(http.StatusOK, gin.H{"items": items})
reports, err := reader.ListVisibleTaskDeploymentReports(c.Request.Context(), actorID, c.Param("id"))
if err != nil {
s.writeError(c, http.StatusInternalServerError, "查询完整部署报告失败", err)
return
}
c.JSON(http.StatusOK, gin.H{"items": items, "reports": reports})
}
func taskDeploymentResults(report deployment.Report) []repository.TaskDeploymentResult {

View File

@ -246,8 +246,8 @@ func TestTaskAPIClosesTaskWhenStartAuditFails(t *testing.T) {
t.Fatalf("任务启动审计失败后尝试未收敛:%+v %v", attempts, err)
}
logs, err := auditRepo.ListAuditLogs(context.Background(), 10)
if err != nil || len(logs) != 0 {
t.Fatalf("启动审计写入失败时不应生成伪造终态审计:%+v %v", logs, err)
if err != nil || len(logs) != 1 || logs[0].Status != "error" || logs[0].Message != items[0].Message || logs[0].TaskID != items[0].ID {
t.Fatalf("启动失败必须由终态事务记录真实失败,不能伪造运行或成功审计:%+v %v", logs, err)
}
}
func TestTaskAPIClosesTaskWhenStartStateUpdateFails(t *testing.T) {

View File

@ -72,6 +72,7 @@ func (s *Server) finishTaskBeforeExecution(task repository.Task, attempt reposit
if err := s.tasks.FinishTaskAttempt(ctx, repository.TaskCompletion{
TaskID: task.ID, AttemptID: attempt.ID, Status: status, Stage: attempt.Stage,
Message: message, FinishedAt: finished,
AuditAction: task.TaskType,
}); err != nil && s.logger != nil {
s.logger.Error("保存任务及尝试终态失败,保留中断恢复状态", "task_id", task.ID, "error", err)
}

View File

@ -110,31 +110,31 @@ func TestAsyncDeadlineBeforeExecutionIsFailureNotUserCancellation(t *testing.T)
}
}
type failingTaskFinishAuditRepository struct{ repository.AuditRepository }
type failingTaskFinishAuditRepository struct{ repository.TaskRepository }
func (r failingTaskFinishAuditRepository) CreateAuditLog(ctx context.Context, item repository.AuditLog) error {
if item.Status == "success" {
func (r failingTaskFinishAuditRepository) FinishTaskAttempt(ctx context.Context, item repository.TaskCompletion) error {
if item.AuditAction != "" {
return errors.New("模拟完成审计写入失败")
}
return r.AuditRepository.CreateAuditLog(ctx, item)
return r.TaskRepository.FinishTaskAttempt(ctx, item)
}
func TestAsyncFinishAuditFailureDoesNotLeaveSuccessfulAttempt(t *testing.T) {
server, tasks, audit, cleanup := newTaskAPITestServer(t, func(context.Context, string) (certificate.IssueResult, deployment.Report, error) {
server, tasks, _, cleanup := newTaskAPITestServer(t, func(context.Context, string) (certificate.IssueResult, deployment.Report, error) {
return certificate.IssueResult{}, deployment.Report{Status: "success"}, nil
})
defer cleanup()
server.audit = failingTaskFinishAuditRepository{audit}
server.tasks = failingTaskFinishAuditRepository{tasks}
response := httptest.NewRecorder()
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil))
task := submittedTask(t, response)
waitTaskWorkersForTest(t, server)
stored, err := tasks.GetTask(t.Context(), task.ID)
if err != nil || stored.Status != "failed" {
if err != nil || !isActiveTaskStatus(stored.Status) || stored.FinishedAt != nil {
t.Fatalf("完成审计失败却标记成功:%+v %v", stored, err)
}
attempts, err := tasks.ListAttempts(t.Context(), task.ID)
if err != nil || len(attempts) != 1 || attempts[0].Status != "failed" || attempts[0].Message != stored.Message {
if err != nil || len(attempts) != 1 || !isActiveTaskStatus(attempts[0].Status) || attempts[0].FinishedAt != nil {
t.Fatalf("任务与尝试终态不一致:%+v %v", attempts, err)
}
}

View File

@ -0,0 +1,71 @@
package httpapi
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
"git.pcux.cn/xuans/Scert/backend/internal/repository"
)
func TestAsyncTaskReportRestoresVerificationAndRecovery(t *testing.T) {
server, tasks, _, cleanup := newTaskAPITestServer(t, func(context.Context, string) (certificate.IssueResult, deployment.Report, error) {
return certificate.IssueResult{}, deployment.Report{
Total: 1, Failed: 1, Status: "failed", VerificationStatus: "failed", RecoveryStatus: "success",
Results: []deployment.Result{{ServerID: "private-report-target", Status: "failed", FailureCode: deployment.FailureRecoveryRestored, Message: "secret-remote-output"}},
}, errors.New("站点校验失败,已恢复")
})
defer cleanup()
response := httptest.NewRecorder()
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil))
task := submittedTask(t, response)
waitTaskWorkersForTest(t, server)
path := "/api/v1/tasks/" + task.ID + "/deployment-results"
response = httptest.NewRecorder()
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodGet, path, nil))
var body struct {
Items []repository.TaskDeploymentResult `json:"items"`
Reports []repository.TaskDeploymentReport `json:"reports"`
}
if response.Code != http.StatusOK || json.Unmarshal(response.Body.Bytes(), &body) != nil || len(body.Items) != 1 || len(body.Reports) != 1 {
t.Fatalf("任务完整报告接口响应错误:%d %s", response.Code, response.Body.String())
}
report := body.Reports[0]
if report.TaskID != task.ID || report.AttemptNumber != 1 || report.Report.VerificationStatus != "failed" || report.Report.RecoveryStatus != "success" || report.Report.Results[0].FailureCode != deployment.FailureRecoveryRestored {
t.Fatalf("接口遗漏验证和恢复报告:%+v", report)
}
if strings.Contains(response.Body.String(), "secret-remote-output") {
t.Fatal("接口泄露原始远端输出")
}
outsider := httpUserSessionCookie(server, "outsider", repository.UserRoleUser)
response = serveUserRequest(server, outsider, http.MethodGet, path, "")
if response.Code != http.StatusForbidden || strings.Contains(response.Body.String(), "private-report-target") {
t.Fatal("完整报告未遵守项目权限")
}
member := httpUserSessionCookie(server, "report-reader", repository.UserRoleUser)
if err := server.projectMembers.GrantProjectRole(t.Context(), "test-admin", domain.DefaultProjectID, "report-reader", domain.ProjectRoleReadOnly); err != nil {
t.Fatal(err)
}
response = serveUserRequest(server, member, http.MethodGet, path, "")
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "private-report-target") {
t.Fatal("只读成员无法读取完整报告")
}
if err := server.projectMembers.RevokeProjectRole(t.Context(), "test-admin", domain.DefaultProjectID, "report-reader", domain.ProjectRoleReadOnly); err != nil {
t.Fatal(err)
}
response = serveUserRequest(server, member, http.MethodGet, path, "")
if response.Code != http.StatusForbidden || strings.Contains(response.Body.String(), "private-report-target") {
t.Fatal("撤权后仍泄露完整报告")
}
visible, err := tasks.ListVisibleTaskDeploymentReports(t.Context(), "report-reader", task.ID)
if err != nil || len(visible) != 0 {
t.Fatalf("报告仓储未实时检查权限:%+v %v", visible, err)
}
}

View File

@ -17,6 +17,7 @@ type ProjectTaskReader interface {
GetVisibleTask(context.Context, string, string) (Task, error)
ListVisibleTaskAttempts(context.Context, string, string) ([]TaskAttempt, error)
ListVisibleTaskDeploymentResults(context.Context, string, string) ([]TaskDeploymentResult, error)
ListVisibleTaskDeploymentReports(context.Context, string, string) ([]TaskDeploymentReport, error)
CancelVisibleTask(context.Context, string, string, string, *time.Time) error
RequestCancelVisibleTask(context.Context, string, string) error
RetryVisibleTask(context.Context, string, string, *time.Time) (Task, TaskAttempt, error)

View File

@ -7,17 +7,21 @@ import (
"fmt"
"strings"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
)
// TaskCompletion 同时关闭任务与对应尝试,避免只保存其中一方的终态。
type TaskCompletion struct {
TaskID string
AttemptID string
Status string
Stage string
Message string
StartedAt *time.Time
FinishedAt time.Time
TaskID string
AttemptID string
Status string
Stage string
Message string
StartedAt *time.Time
FinishedAt time.Time
Report *deployment.Report
AuditAction string
}
func (r *SQLTaskRepository) FinishTaskAttempt(ctx context.Context, completion TaskCompletion) error {
@ -66,6 +70,16 @@ func (r *SQLTaskRepository) FinishTaskAttempt(ctx context.Context, completion Ta
} else if count != 1 {
return sql.ErrNoRows
}
if completion.Report != nil {
if err := r.recordCompletionReport(ctx, tx, completion); err != nil {
return err
}
}
if completion.AuditAction != "" {
if err := r.recordCompletionAudit(ctx, tx, completion); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("提交任务收尾事务失败:%w", err)
}

View File

@ -0,0 +1,30 @@
package repository
import (
"context"
"database/sql"
"fmt"
)
// 完成审计从已锁定的任务取资源、操作者及项目,不信任外部传入的归属字段。
func (r *SQLTaskRepository) recordCompletionAudit(ctx context.Context, tx *sql.Tx, completion TaskCompletion) error {
status := completion.Status
if status == "failed" {
status = "error"
}
result, 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 ?, CASE WHEN scope_type = 'system' THEN 'management_certificate' ELSE 'domain' END,
domain_id, ?, ?, ?, scope_type, project_id, id, operator_id
FROM tasks WHERE id = ?`), completion.AuditAction, status, completion.Message,
completion.FinishedAt.UTC(), completion.TaskID)
if err != nil {
return fmt.Errorf("保存任务完成审计失败:%w", err)
}
if count, err := result.RowsAffected(); err != nil {
return fmt.Errorf("读取任务完成审计写入结果失败:%w", err)
} else if count != 1 {
return sql.ErrNoRows
}
return nil
}

View File

@ -0,0 +1,109 @@
package repository
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
)
// TaskDeploymentReport 保存一次尝试的完整公开报告,不包含远端输出或私钥。
type TaskDeploymentReport struct {
TaskID string `json:"task_id"`
AttemptID string `json:"attempt_id"`
AttemptNumber int `json:"attempt_number"`
Report deployment.Report `json:"report"`
CreatedAt time.Time `json:"created_at"`
}
func publicTaskReport(report deployment.Report) deployment.Report {
report.Results = append([]deployment.Result{}, report.Results...)
for index := range report.Results {
report.Results[index] = deployment.PublicResult(report.Results[index])
}
return report
}
// 报告、逐目标结果和两条终态共用事务;任一保存失败均保留活动记录供恢复接管。
func (r *SQLTaskRepository) recordCompletionReport(ctx context.Context, tx *sql.Tx, completion TaskCompletion) error {
var attemptNumber int
if err := tx.QueryRowContext(ctx, r.query(`SELECT attempt_number FROM task_attempts WHERE id = ? AND task_id = ?`),
completion.AttemptID, completion.TaskID).Scan(&attemptNumber); err != nil {
return fmt.Errorf("读取报告所属尝试失败:%w", err)
}
report := publicTaskReport(*completion.Report)
content, err := json.Marshal(report)
if err != nil {
return fmt.Errorf("序列化部署报告失败:%w", err)
}
if _, err := tx.ExecContext(ctx, r.query(`INSERT INTO task_deployment_reports
(task_id, attempt_id, attempt_number, report_json, created_at) VALUES (?, ?, ?, ?, ?)`),
completion.TaskID, completion.AttemptID, attemptNumber, string(content), completion.FinishedAt.UTC()); err != nil {
return fmt.Errorf("保存完整部署报告失败:%w", err)
}
if _, err := tx.ExecContext(ctx, r.query(`DELETE FROM task_deployment_results WHERE task_id = ? AND attempt_id = ?`),
completion.TaskID, completion.AttemptID); err != nil {
return fmt.Errorf("清理尝试旧结果失败:%w", err)
}
for index, result := range report.Results {
if _, err := tx.ExecContext(ctx, r.query(`INSERT INTO task_deployment_results
(task_id, attempt_id, attempt_number, result_index, server_id, server_name, status, message, backup_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), completion.TaskID, completion.AttemptID, attemptNumber,
index, result.ServerID, result.Server, result.Status, result.Message, result.BackupID, completion.FinishedAt.UTC()); err != nil {
return fmt.Errorf("保存报告逐服务器结果失败:%w", err)
}
}
return nil
}
func (r *SQLTaskRepository) ListTaskDeploymentReports(ctx context.Context, taskID string) ([]TaskDeploymentReport, error) {
if r == nil || r.db == nil || strings.TrimSpace(taskID) == "" {
return nil, errors.New("数据库连接和任务编号不能为空")
}
return r.listTaskReports(ctx, `SELECT task_id, attempt_id, attempt_number, report_json, created_at
FROM task_deployment_reports WHERE task_id = ? ORDER BY attempt_number`, taskID)
}
func (r *SQLTaskRepository) ListVisibleTaskDeploymentReports(ctx context.Context, actorID, taskID string) ([]TaskDeploymentReport, error) {
if r == nil || r.db == nil {
return nil, errors.New("数据库连接不能为空")
}
predicate, args := taskVisibilityPredicate("tasks", domain.PermissionTaskRead)
args[0] = strings.TrimSpace(actorID)
args = append(args, strings.TrimSpace(taskID))
return r.listTaskReports(ctx, `SELECT reports.task_id, reports.attempt_id, reports.attempt_number, reports.report_json, reports.created_at
FROM task_deployment_reports reports JOIN tasks ON tasks.id = reports.task_id
WHERE `+predicate+` AND reports.task_id = ? ORDER BY reports.attempt_number`, args...)
}
func (r *SQLTaskRepository) listTaskReports(ctx context.Context, query string, args ...any) ([]TaskDeploymentReport, error) {
rows, err := r.db.QueryContext(ctx, r.query(query), args...)
if err != nil {
return nil, fmt.Errorf("查询完整部署报告失败:%w", err)
}
defer rows.Close()
items := make([]TaskDeploymentReport, 0)
for rows.Next() {
var item TaskDeploymentReport
var content string
if err := rows.Scan(&item.TaskID, &item.AttemptID, &item.AttemptNumber, &content, &item.CreatedAt); err != nil {
return nil, fmt.Errorf("读取完整部署报告失败:%w", err)
}
if err := json.Unmarshal([]byte(content), &item.Report); err != nil {
return nil, fmt.Errorf("解析完整部署报告失败:%w", err)
}
item.Report = publicTaskReport(item.Report)
item.CreatedAt = item.CreatedAt.UTC()
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("读取完整部署报告列表失败:%w", err)
}
return items, nil
}

View File

@ -0,0 +1,123 @@
package repository
import (
"strings"
"testing"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
)
func completionTestReport() *deployment.Report {
return &deployment.Report{
Total: 2, Success: 1, Failed: 1, Status: "failed", VerificationStatus: "failed", RecoveryStatus: "unconfirmed",
Results: []deployment.Result{
{ServerID: "server-1", Server: "第一台", Status: "success", BackupID: "backup-1"},
{ServerID: "server-2", Server: "第二台", Status: "failed", FailureCode: deployment.FailureRecoveryUnknown, Message: "secret-output-marker"},
},
}
}
func TestTaskCompletionReportPersistsAcrossRepositoryRestart(t *testing.T) {
repo, db := newTaskRepositoryTest(t)
defer db.Close()
completion := seedTaskCompletion(t, repo)
completion.Status = "failed"
completion.Report = completionTestReport()
completion.AuditAction = "certificate_deploy"
if err := repo.FinishTaskAttempt(t.Context(), completion); err != nil {
t.Fatal(err)
}
reader := NewSQLTaskRepository(db)
var auditStatus, scope, projectID, resourceID string
if err := db.QueryRowContext(t.Context(), `SELECT status, scope_type, project_id, resource_id FROM audit_logs WHERE task_id = ?`,
completion.TaskID).Scan(&auditStatus, &scope, &projectID, &resourceID); err != nil || auditStatus != "error" || scope != "project" || projectID != "default" || resourceID != "domain-1" {
t.Fatalf("完成审计与任务状态或归属不一致:%s %s %s %s %v", auditStatus, scope, projectID, resourceID, err)
}
reports, err := reader.ListTaskDeploymentReports(t.Context(), completion.TaskID)
if err != nil || len(reports) != 1 {
t.Fatalf("完整报告未保存:%+v %v", reports, err)
}
stored := reports[0]
if stored.AttemptNumber != 1 || stored.AttemptID != completion.AttemptID || stored.Report.VerificationStatus != "failed" || stored.Report.RecoveryStatus != "unconfirmed" || len(stored.Report.Results) != 2 {
t.Fatalf("重读后验证或恢复证据丢失:%+v", stored)
}
if stored.Report.Results[1].FailureCode != deployment.FailureRecoveryUnknown || strings.Contains(stored.Report.Results[1].Message, "secret-output-marker") {
t.Fatal("报告未保留固定诊断或泄露远端输出")
}
var raw string
if err := db.QueryRowContext(t.Context(), `SELECT report_json FROM task_deployment_reports WHERE task_id = ?`, completion.TaskID).Scan(&raw); err != nil || strings.Contains(raw, "secret-output-marker") {
t.Fatalf("持久化前未脱敏:%v", err)
}
results, err := reader.ListTaskDeploymentResults(t.Context(), completion.TaskID)
if err != nil || len(results) != 2 || results[1].Message != stored.Report.Results[1].Message {
t.Fatalf("逐目标结果与报告不一致:%+v %v", results, err)
}
if completion.Report.Results[1].Message != "secret-output-marker" {
t.Fatal("保存报告不能原地修改调用者结果")
}
if _, err := db.ExecContext(t.Context(), `INSERT INTO users (id, username, password_hash, role, status)
VALUES ('report-admin', 'report-admin', 'test', 'admin', 'active')`); err != nil {
t.Fatal(err)
}
if _, err := db.ExecContext(t.Context(), `INSERT INTO project_members (project_id, user_id, role)
VALUES ('default', 'report-admin', 'project_admin')`); err != nil {
t.Fatal(err)
}
_, retry, err := repo.RetryVisibleTask(t.Context(), "report-admin", completion.TaskID, nil)
if err != nil {
t.Fatal(err)
}
completion.AttemptID = retry.ID
completion.Status = "success"
completion.FinishedAt = time.Now().UTC()
completion.Report = &deployment.Report{Status: "success", VerificationStatus: "success"}
if err := repo.FinishTaskAttempt(t.Context(), completion); err != nil {
t.Fatal(err)
}
reports, err = reader.ListTaskDeploymentReports(t.Context(), completion.TaskID)
if err != nil || len(reports) != 2 || reports[0].Report.RecoveryStatus != "unconfirmed" || reports[1].AttemptNumber != 2 || reports[1].Report.VerificationStatus != "success" {
t.Fatalf("重试覆盖或混用了旧报告:%+v %v", reports, err)
}
if reports[1].Report.Results == nil {
t.Fatal("零目标报告必须返回空数组而非空值,避免前端忽略验证状态")
}
}
func TestTaskCompletionReportFailureRollsBackAllRecords(t *testing.T) {
for _, table := range []string{"task_deployment_reports", "task_deployment_results", "audit_logs"} {
t.Run(table, func(t *testing.T) {
repo, db := newTaskRepositoryTest(t)
defer db.Close()
completion := seedTaskCompletion(t, repo)
completion.Report = completionTestReport()
completion.AuditAction = "certificate_deploy"
if _, err := db.ExecContext(t.Context(), `CREATE TRIGGER reject_report BEFORE INSERT ON `+table+` BEGIN SELECT RAISE(ABORT, 'injected report failure'); END`); err != nil {
t.Fatal(err)
}
if err := repo.FinishTaskAttempt(t.Context(), completion); err == nil {
t.Fatal("报告保存失败必须拒绝提交终态")
}
task, err := repo.GetTask(t.Context(), completion.TaskID)
if err != nil || task.Status != "running" || task.FinishedAt != nil {
t.Fatalf("报告失败但任务被提交:%+v %v", task, err)
}
attempts, err := repo.ListAttempts(t.Context(), completion.TaskID)
if err != nil || len(attempts) != 1 || attempts[0].Status != "running" || attempts[0].FinishedAt != nil {
t.Fatalf("报告失败但尝试被提交:%+v %v", attempts, err)
}
reports, err := repo.ListTaskDeploymentReports(t.Context(), completion.TaskID)
if err != nil || len(reports) != 0 {
t.Fatalf("出现部分提交的报告:%+v %v", reports, err)
}
results, err := repo.ListTaskDeploymentResults(t.Context(), completion.TaskID)
if err != nil || len(results) != 0 {
t.Fatalf("出现部分提交的目标结果:%+v %v", results, err)
}
var auditCount int
if err := db.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM audit_logs WHERE task_id = ?`, completion.TaskID).Scan(&auditCount); err != nil || auditCount != 0 {
t.Fatalf("出现部分提交的完成审计:%d %v", auditCount, err)
}
})
}
}

View File

@ -72,6 +72,7 @@ type TaskRepository interface {
ListAttempts(context.Context, string) ([]TaskAttempt, error)
RecordTaskDeploymentResults(context.Context, string, string, int, []TaskDeploymentResult) error
ListTaskDeploymentResults(context.Context, string) ([]TaskDeploymentResult, error)
ListTaskDeploymentReports(context.Context, string) ([]TaskDeploymentReport, error)
}
type SQLTaskRepository struct {

View File

@ -1,5 +1,17 @@
# Scert 整改任务执行清单
## 2026年10月2日17:30 完整异步报告整改入口
需求已沿用既定清单确认:异步执行后必须保留完整部署、站点验证与恢复报告;不能仅由逐目标成功推断整个任务成功。本轮仍只处理 Linux 开发与自动回归,不清线上数据,不部署。
- [x] 上轮终态事务与取消一致性整改已提交为 `8a8b1a5`,本轮已成功推送,解除此前远程同步超时。
- [x] 接续记录确认上轮普通专项重复二十次通过;竞争检测已中断退出一百三十,不能记为通过。本轮已轮询原 SSH 会话,返回空闲提示符,不重新启动无限制负载。
- [~] 增加完整报告三库迁移;报告、逐服务器结果及任务与尝试终态共同提交,保存失败整体回滚。
- [~] 前端优先读取最新尝试的持久化报告,保留站点验证与恢复状态;旧历史不伪造验证结论。
- [ ] 新增报告写入故障、事务回滚、重试隔离、权限、脱敏、刷新恢复和迁移回归。
- [ ] 在第二台隔离源码运行最新全量后端回归与静态检查;本机运行前端测试和构建。自动回归不替代正式部署验收。
- [!] 完成审计尚未与上述事务统一;竞争检测、其他长操作、关停与重启边界、历史恢复证据、通知重置、三库首次 Web、备份恢复与最终验收仍未关闭。
## 2026年10月2日17:11 异步收尾一致性整改
本节接续 16:47 的任务,不缩减原始 Linux 上线范围;本批仍未部署。

View File

@ -6,8 +6,15 @@ export function taskIsPending(task) {
return !terminalStatuses.has(task?.status);
}
export function latestDeploymentReport(task, attempts, results) {
export function latestDeploymentReport(task, attempts, results, reports = []) {
const latest = Math.max(0, ...attempts.map((item) => item.attempt_number || 0));
if (taskIsPending(task) || latest === 0) return null;
const attempt = attempts.find((item) => item.attempt_number === latest);
const stored = reports.find((item) => item.task_id === task.id &&
item.attempt_id === attempt.id && item.attempt_number === latest);
if (stored && Array.isArray(stored.report?.results)) {
return { ...stored.report, status: task.status };
}
const items = results.filter((item) => item.attempt_number === latest);
if (taskIsPending(task) || latest === 0 || items.length === 0) return null;
return {
@ -58,7 +65,14 @@ export function useTaskMonitor({ fetchAdmin, tasks, deploymentResult, showTaskDe
if (!taskIsPending(task) && latestAttempt && latestAttempt.status !== task.status) {
throw new Error("任务记录正在更新,将重新查询一致的执行结果");
}
return { task, attempts: attempts.items, report: latestDeploymentReport(task, attempts.items, results.items) };
if (results.reports !== undefined && !Array.isArray(results.reports)) {
throw new Error("完整部署报告响应不完整,将自动重试");
}
return {
task,
attempts: attempts.items,
report: latestDeploymentReport(task, attempts.items, results.items, results.reports),
};
}
async function poll() {

View File

@ -8,6 +8,50 @@ import { deferred, mountComposable } from "../../test-support/mount.js";
const initialTask = { id: "async-task", domain_id: "domain-1", task_type: "certificate_deploy", status: "running" };
const target = { server_id: "server-1", server: "部署目标", status: "success", attempt_number: 1 };
test("完整报告恢复站点验证与回滚事实,不被逐目标结果覆盖", () => {
const task = { ...initialTask, status: "failed" };
const attempts = [{ id: "attempt-2", attempt_number: 2, status: "failed" }];
const persisted = {
task_id: task.id, attempt_id: "attempt-2", attempt_number: 2,
report: {
total: 1, success: 0, failed: 1, status: "failed",
verification_status: "failed", recovery_status: "success",
results: [{ ...target, status: "failed", failure_code: "recovery_restored" }],
},
};
const report = latestDeploymentReport(task, attempts, [target], [persisted]);
assert.equal(report.verification_status, "failed");
assert.equal(report.recovery_status, "success");
assert.equal(report.results[0].failure_code, "recovery_restored");
assert.equal(report.success, 0);
assert.match(deploymentSummary(report), /恢复/);
});
test("完整报告按任务和尝试精确匹配,不复用旧尝试或其他任务报告", () => {
const task = { ...initialTask, status: "success" };
const attempts = [{ id: "attempt-2", attempt_number: 2 }];
for (const mismatch of [
{ task_id: "another-task", attempt_id: "attempt-2", attempt_number: 2 },
{ task_id: task.id, attempt_id: "attempt-1", attempt_number: 2 },
{ task_id: task.id, attempt_id: "attempt-2", attempt_number: 1 },
]) {
assert.equal(latestDeploymentReport(task, attempts, [], [{
...mismatch, report: { status: "success", verification_status: "success", results: [] },
}]), null);
}
});
test("完整报告无逐目标结果仍可读取,执行成功不能覆盖任务收尾失败", () => {
const task = { ...initialTask, status: "failed" };
const attempts = [{ id: "attempt-1", attempt_number: 1 }];
const reports = [{
task_id: task.id, attempt_id: "attempt-1", attempt_number: 1,
report: { status: "success", total: 0, success: 0, failed: 0, results: [] },
}];
assert.equal(latestDeploymentReport(task, attempts, [], reports).status, "failed");
assert.equal(latestDeploymentReport(initialTask, attempts, [], reports), null);
});
function taskFetcher(readTask, onPost = () => {}) {
return async (path, options = {}) => {
const task = readTask();
@ -23,6 +67,30 @@ function taskFetcher(readTask, onPost = () => {}) {
};
}
test("页面刷新查询完整报告并展示恢复状态", async (t) => {
const task = { ...initialTask, status: "failed" };
const fetchTask = taskFetcher(() => task);
const { state } = await mountComposable(t, useDashboard, (path, options) => {
if (path.endsWith("/attempts")) {
return Response.json({ items: [{ id: "stored-attempt", attempt_number: 1, status: "failed" }] });
}
if (path.endsWith("/deployment-results")) {
return Response.json({ items: [target], reports: [{
task_id: task.id, attempt_id: "stored-attempt", attempt_number: 1,
report: {
status: "failed", total: 1, success: 0, failed: 1,
verification_status: "failed", recovery_status: "unconfirmed", results: [target],
},
}] });
}
return fetchTask(path, options);
});
await state.openTaskDetails(task);
assert.equal(state.taskDeploymentReport.value.verification_status, "failed");
assert.equal(state.taskDeploymentReport.value.recovery_status, "unconfirmed");
assert.match(deploymentSummary(state.taskDeploymentReport.value), /恢复/);
});
test("异步部署接收后不误报成功或缺失结果,最终失败不被逐目标成功覆盖", async (t) => {
let current = { ...initialTask };
const posts = [];