保存完整任务报告及完成审计原子化基线,最新回归待确认
This commit is contained in:
parent
8a8b1a5e0e
commit
4e8d50919a
@ -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"
|
||||
}
|
||||
|
||||
@ -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)
|
||||
);
|
||||
@ -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)
|
||||
);
|
||||
@ -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)
|
||||
);
|
||||
@ -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]+\)$`)
|
||||
)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
71
backend/internal/httpapi/task_report_test.go
Normal file
71
backend/internal/httpapi/task_report_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -7,6 +7,8 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
|
||||
)
|
||||
|
||||
// TaskCompletion 同时关闭任务与对应尝试,避免只保存其中一方的终态。
|
||||
@ -18,6 +20,8 @@ type TaskCompletion struct {
|
||||
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)
|
||||
}
|
||||
|
||||
30
backend/internal/repository/task_completion_audit.go
Normal file
30
backend/internal/repository/task_completion_audit.go
Normal 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
|
||||
}
|
||||
109
backend/internal/repository/task_report.go
Normal file
109
backend/internal/repository/task_report.go
Normal 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
|
||||
}
|
||||
123
backend/internal/repository/task_report_test.go
Normal file
123
backend/internal/repository/task_report_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -1,5 +1,17 @@
|
||||
# Scert 整改任务执行清单
|
||||
|
||||
## 2026年10月2日17:30 完整异步报告整改入口
|
||||
|
||||
需求已沿用既定清单确认:异步执行后必须保留完整部署、站点验证与恢复报告;不能仅由逐目标成功推断整个任务成功。本轮仍只处理 Linux 开发与自动回归,不清线上数据,不部署。
|
||||
|
||||
- [x] 上轮终态事务与取消一致性整改已提交为 `8a8b1a5`,本轮已成功推送,解除此前远程同步超时。
|
||||
- [x] 接续记录确认上轮普通专项重复二十次通过;竞争检测已中断退出一百三十,不能记为通过。本轮已轮询原 SSH 会话,返回空闲提示符,不重新启动无限制负载。
|
||||
- [~] 增加完整报告三库迁移;报告、逐服务器结果及任务与尝试终态共同提交,保存失败整体回滚。
|
||||
- [~] 前端优先读取最新尝试的持久化报告,保留站点验证与恢复状态;旧历史不伪造验证结论。
|
||||
- [ ] 新增报告写入故障、事务回滚、重试隔离、权限、脱敏、刷新恢复和迁移回归。
|
||||
- [ ] 在第二台隔离源码运行最新全量后端回归与静态检查;本机运行前端测试和构建。自动回归不替代正式部署验收。
|
||||
- [!] 完成审计尚未与上述事务统一;竞争检测、其他长操作、关停与重启边界、历史恢复证据、通知重置、三库首次 Web、备份恢复与最终验收仍未关闭。
|
||||
|
||||
## 2026年10月2日17:11 异步收尾一致性整改
|
||||
|
||||
本节接续 16:47 的任务,不缩减原始 Linux 上线范围;本批仍未部署。
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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 = [];
|
||||
|
||||
Loading…
Reference in New Issue
Block a user