保存完整任务报告及完成审计原子化基线,最新回归待确认
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/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/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/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
|
var migrationFiles embed.FS
|
||||||
|
|
||||||
func Migrate(ctx context.Context, db *sql.DB, driver string) error {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const latestMigrationVersion = 19
|
const latestMigrationVersion = 20
|
||||||
|
|
||||||
// currentMigrationVersion 在执行业务迁移前拒绝新版数据库及不连续的迁移历史。
|
// currentMigrationVersion 在执行业务迁移前拒绝新版数据库及不连续的迁移历史。
|
||||||
func currentMigrationVersion(ctx context.Context, db *sql.DB) (int, error) {
|
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)
|
return applySQLiteTaskMigration(ctx, db, statements, version)
|
||||||
}
|
}
|
||||||
if driver == "mysql" {
|
if driver == "mysql" {
|
||||||
if version >= 15 && version <= 17 {
|
if (version >= 15 && version <= 17) || version == 20 {
|
||||||
return applyMySQLDeploymentMigration(ctx, db, version, statements)
|
return applyMySQLDeploymentMigration(ctx, db, version, statements)
|
||||||
}
|
}
|
||||||
if version == 14 {
|
if version == 14 {
|
||||||
@ -237,6 +238,8 @@ func migrationName(version int) string {
|
|||||||
return "recovery_evidence"
|
return "recovery_evidence"
|
||||||
case 19:
|
case 19:
|
||||||
return "password_auth_only"
|
return "password_auth_only"
|
||||||
|
case 20:
|
||||||
|
return "task_deployment_reports"
|
||||||
default:
|
default:
|
||||||
return "unknown"
|
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 (
|
var (
|
||||||
mysqlDeploymentTablePattern = regexp.MustCompile(`^CREATE TABLE ([a-z_]+) \($`)
|
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_, ]+)\)$`)
|
mysqlDeploymentIndexPattern = regexp.MustCompile(`^(PRIMARY KEY|UNIQUE KEY ([a-z_]+)|INDEX ([a-z_]+)) \(([a-z_, ]+)\)$`)
|
||||||
mysqlIntegerWidthPattern = regexp.MustCompile(`^int\([0-9]+\)$`)
|
mysqlIntegerWidthPattern = regexp.MustCompile(`^int\([0-9]+\)$`)
|
||||||
)
|
)
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestMySQLDeploymentShapeDefinitions(t *testing.T) {
|
func TestMySQLDeploymentShapeDefinitions(t *testing.T) {
|
||||||
for version := 15; version <= 17; version++ {
|
for _, version := range []int{15, 16, 17, 20} {
|
||||||
statements := mysqlDeploymentStatements(t, version)
|
statements := mysqlDeploymentStatements(t, version)
|
||||||
shape, err := parseMySQLDeploymentShape(statements[0])
|
shape, err := parseMySQLDeploymentShape(statements[0])
|
||||||
if err != nil || len(shape.Columns) == 0 || len(shape.Indexes) == 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)
|
rows, err := query(ctx)
|
||||||
if rows != nil {
|
if rows != nil {
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
|
// 单执行线程也要覆盖结果集已打开后的取消,保留其余轮次的自然竞争。
|
||||||
|
if attempt%2 == 0 {
|
||||||
|
runtime.Gosched()
|
||||||
|
}
|
||||||
var value int
|
var value int
|
||||||
if scanErr := rows.Scan(&value); scanErr != nil {
|
if scanErr := rows.Scan(&value); scanErr != nil {
|
||||||
err = scanErr
|
err = scanErr
|
||||||
|
|||||||
@ -180,15 +180,6 @@ func (s *Server) runCertificateTask(ctx context.Context, task repository.Task, a
|
|||||||
}
|
}
|
||||||
attempt.Stage = stage
|
attempt.Stage = stage
|
||||||
s.finishTaskBeforeExecution(task, attempt, "failed", failureMessage)
|
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
|
return
|
||||||
}
|
}
|
||||||
if s.audit != nil {
|
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)
|
finishCtx, finishCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer finishCancel()
|
defer finishCancel()
|
||||||
deploymentReport = sanitizeDeploymentReport(deploymentReport)
|
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()
|
finished := time.Now().UTC()
|
||||||
status := "success"
|
status := "success"
|
||||||
message := successMessage
|
message := successMessage
|
||||||
@ -240,9 +227,6 @@ func (s *Server) runCertificateTask(ctx context.Context, task repository.Task, a
|
|||||||
} else if partial {
|
} else if partial {
|
||||||
status = "partial_success"
|
status = "partial_success"
|
||||||
}
|
}
|
||||||
if recordErr != nil {
|
|
||||||
status, message = "failed", "部署结果保存失败,请核查实际部署状态后再重试"
|
|
||||||
}
|
|
||||||
s.taskMu.Lock()
|
s.taskMu.Lock()
|
||||||
defer s.taskMu.Unlock()
|
defer s.taskMu.Unlock()
|
||||||
if persisted, err := s.tasks.GetTask(finishCtx, task.ID); err != nil {
|
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 = "任务已停止;已执行操作是否回滚请查看部署结果"
|
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{
|
if err := s.tasks.FinishTaskAttempt(finishCtx, repository.TaskCompletion{
|
||||||
TaskID: task.ID, AttemptID: attempt.ID, Status: status, Stage: stage,
|
TaskID: task.ID, AttemptID: attempt.ID, Status: status, Stage: stage,
|
||||||
Message: message, StartedAt: &now, FinishedAt: finished,
|
Message: message, StartedAt: &now, FinishedAt: finished,
|
||||||
|
Report: &deploymentReport, AuditAction: auditAction,
|
||||||
}); err != nil && s.logger != nil {
|
}); err != nil && s.logger != nil {
|
||||||
s.logger.Error("保存任务及尝试终态失败,保留中断恢复状态", "task_id", task.ID, "error", err)
|
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)
|
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 {
|
func taskDeploymentResults(report deployment.Report) []repository.TaskDeploymentResult {
|
||||||
|
|||||||
@ -246,8 +246,8 @@ func TestTaskAPIClosesTaskWhenStartAuditFails(t *testing.T) {
|
|||||||
t.Fatalf("任务启动审计失败后尝试未收敛:%+v %v", attempts, err)
|
t.Fatalf("任务启动审计失败后尝试未收敛:%+v %v", attempts, err)
|
||||||
}
|
}
|
||||||
logs, err := auditRepo.ListAuditLogs(context.Background(), 10)
|
logs, err := auditRepo.ListAuditLogs(context.Background(), 10)
|
||||||
if err != nil || len(logs) != 0 {
|
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)
|
t.Fatalf("启动失败必须由终态事务记录真实失败,不能伪造运行或成功审计:%+v %v", logs, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
func TestTaskAPIClosesTaskWhenStartStateUpdateFails(t *testing.T) {
|
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{
|
if err := s.tasks.FinishTaskAttempt(ctx, repository.TaskCompletion{
|
||||||
TaskID: task.ID, AttemptID: attempt.ID, Status: status, Stage: attempt.Stage,
|
TaskID: task.ID, AttemptID: attempt.ID, Status: status, Stage: attempt.Stage,
|
||||||
Message: message, FinishedAt: finished,
|
Message: message, FinishedAt: finished,
|
||||||
|
AuditAction: task.TaskType,
|
||||||
}); err != nil && s.logger != nil {
|
}); err != nil && s.logger != nil {
|
||||||
s.logger.Error("保存任务及尝试终态失败,保留中断恢复状态", "task_id", task.ID, "error", err)
|
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 {
|
func (r failingTaskFinishAuditRepository) FinishTaskAttempt(ctx context.Context, item repository.TaskCompletion) error {
|
||||||
if item.Status == "success" {
|
if item.AuditAction != "" {
|
||||||
return errors.New("模拟完成审计写入失败")
|
return errors.New("模拟完成审计写入失败")
|
||||||
}
|
}
|
||||||
return r.AuditRepository.CreateAuditLog(ctx, item)
|
return r.TaskRepository.FinishTaskAttempt(ctx, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAsyncFinishAuditFailureDoesNotLeaveSuccessfulAttempt(t *testing.T) {
|
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
|
return certificate.IssueResult{}, deployment.Report{Status: "success"}, nil
|
||||||
})
|
})
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
server.audit = failingTaskFinishAuditRepository{audit}
|
server.tasks = failingTaskFinishAuditRepository{tasks}
|
||||||
response := httptest.NewRecorder()
|
response := httptest.NewRecorder()
|
||||||
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil))
|
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil))
|
||||||
task := submittedTask(t, response)
|
task := submittedTask(t, response)
|
||||||
waitTaskWorkersForTest(t, server)
|
waitTaskWorkersForTest(t, server)
|
||||||
stored, err := tasks.GetTask(t.Context(), task.ID)
|
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)
|
t.Fatalf("完成审计失败却标记成功:%+v %v", stored, err)
|
||||||
}
|
}
|
||||||
attempts, err := tasks.ListAttempts(t.Context(), task.ID)
|
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)
|
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)
|
GetVisibleTask(context.Context, string, string) (Task, error)
|
||||||
ListVisibleTaskAttempts(context.Context, string, string) ([]TaskAttempt, error)
|
ListVisibleTaskAttempts(context.Context, string, string) ([]TaskAttempt, error)
|
||||||
ListVisibleTaskDeploymentResults(context.Context, string, string) ([]TaskDeploymentResult, error)
|
ListVisibleTaskDeploymentResults(context.Context, string, string) ([]TaskDeploymentResult, error)
|
||||||
|
ListVisibleTaskDeploymentReports(context.Context, string, string) ([]TaskDeploymentReport, error)
|
||||||
CancelVisibleTask(context.Context, string, string, string, *time.Time) error
|
CancelVisibleTask(context.Context, string, string, string, *time.Time) error
|
||||||
RequestCancelVisibleTask(context.Context, string, string) error
|
RequestCancelVisibleTask(context.Context, string, string) error
|
||||||
RetryVisibleTask(context.Context, string, string, *time.Time) (Task, TaskAttempt, error)
|
RetryVisibleTask(context.Context, string, string, *time.Time) (Task, TaskAttempt, error)
|
||||||
|
|||||||
@ -7,17 +7,21 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TaskCompletion 同时关闭任务与对应尝试,避免只保存其中一方的终态。
|
// TaskCompletion 同时关闭任务与对应尝试,避免只保存其中一方的终态。
|
||||||
type TaskCompletion struct {
|
type TaskCompletion struct {
|
||||||
TaskID string
|
TaskID string
|
||||||
AttemptID string
|
AttemptID string
|
||||||
Status string
|
Status string
|
||||||
Stage string
|
Stage string
|
||||||
Message string
|
Message string
|
||||||
StartedAt *time.Time
|
StartedAt *time.Time
|
||||||
FinishedAt time.Time
|
FinishedAt time.Time
|
||||||
|
Report *deployment.Report
|
||||||
|
AuditAction string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *SQLTaskRepository) FinishTaskAttempt(ctx context.Context, completion TaskCompletion) error {
|
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 {
|
} else if count != 1 {
|
||||||
return sql.ErrNoRows
|
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 {
|
if err := tx.Commit(); err != nil {
|
||||||
return fmt.Errorf("提交任务收尾事务失败:%w", err)
|
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)
|
ListAttempts(context.Context, string) ([]TaskAttempt, error)
|
||||||
RecordTaskDeploymentResults(context.Context, string, string, int, []TaskDeploymentResult) error
|
RecordTaskDeploymentResults(context.Context, string, string, int, []TaskDeploymentResult) error
|
||||||
ListTaskDeploymentResults(context.Context, string) ([]TaskDeploymentResult, error)
|
ListTaskDeploymentResults(context.Context, string) ([]TaskDeploymentResult, error)
|
||||||
|
ListTaskDeploymentReports(context.Context, string) ([]TaskDeploymentReport, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type SQLTaskRepository struct {
|
type SQLTaskRepository struct {
|
||||||
|
|||||||
@ -1,5 +1,17 @@
|
|||||||
# Scert 整改任务执行清单
|
# Scert 整改任务执行清单
|
||||||
|
|
||||||
|
## 2026年10月2日17:30 完整异步报告整改入口
|
||||||
|
|
||||||
|
需求已沿用既定清单确认:异步执行后必须保留完整部署、站点验证与恢复报告;不能仅由逐目标成功推断整个任务成功。本轮仍只处理 Linux 开发与自动回归,不清线上数据,不部署。
|
||||||
|
|
||||||
|
- [x] 上轮终态事务与取消一致性整改已提交为 `8a8b1a5`,本轮已成功推送,解除此前远程同步超时。
|
||||||
|
- [x] 接续记录确认上轮普通专项重复二十次通过;竞争检测已中断退出一百三十,不能记为通过。本轮已轮询原 SSH 会话,返回空闲提示符,不重新启动无限制负载。
|
||||||
|
- [~] 增加完整报告三库迁移;报告、逐服务器结果及任务与尝试终态共同提交,保存失败整体回滚。
|
||||||
|
- [~] 前端优先读取最新尝试的持久化报告,保留站点验证与恢复状态;旧历史不伪造验证结论。
|
||||||
|
- [ ] 新增报告写入故障、事务回滚、重试隔离、权限、脱敏、刷新恢复和迁移回归。
|
||||||
|
- [ ] 在第二台隔离源码运行最新全量后端回归与静态检查;本机运行前端测试和构建。自动回归不替代正式部署验收。
|
||||||
|
- [!] 完成审计尚未与上述事务统一;竞争检测、其他长操作、关停与重启边界、历史恢复证据、通知重置、三库首次 Web、备份恢复与最终验收仍未关闭。
|
||||||
|
|
||||||
## 2026年10月2日17:11 异步收尾一致性整改
|
## 2026年10月2日17:11 异步收尾一致性整改
|
||||||
|
|
||||||
本节接续 16:47 的任务,不缩减原始 Linux 上线范围;本批仍未部署。
|
本节接续 16:47 的任务,不缩减原始 Linux 上线范围;本批仍未部署。
|
||||||
|
|||||||
@ -6,8 +6,15 @@ export function taskIsPending(task) {
|
|||||||
return !terminalStatuses.has(task?.status);
|
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));
|
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);
|
const items = results.filter((item) => item.attempt_number === latest);
|
||||||
if (taskIsPending(task) || latest === 0 || items.length === 0) return null;
|
if (taskIsPending(task) || latest === 0 || items.length === 0) return null;
|
||||||
return {
|
return {
|
||||||
@ -58,7 +65,14 @@ export function useTaskMonitor({ fetchAdmin, tasks, deploymentResult, showTaskDe
|
|||||||
if (!taskIsPending(task) && latestAttempt && latestAttempt.status !== task.status) {
|
if (!taskIsPending(task) && latestAttempt && latestAttempt.status !== task.status) {
|
||||||
throw new Error("任务记录正在更新,将重新查询一致的执行结果");
|
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() {
|
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 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 };
|
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 = () => {}) {
|
function taskFetcher(readTask, onPost = () => {}) {
|
||||||
return async (path, options = {}) => {
|
return async (path, options = {}) => {
|
||||||
const task = readTask();
|
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) => {
|
test("异步部署接收后不误报成功或缺失结果,最终失败不被逐目标成功覆盖", async (t) => {
|
||||||
let current = { ...initialTask };
|
let current = { ...initialTask };
|
||||||
const posts = [];
|
const posts = [];
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user