保存后台取消结果修复和报告迁移实库回归基线
This commit is contained in:
parent
099351cd9e
commit
cf3bc7aaa5
54
backend/internal/app/certificate_task_cancellation_test.go
Normal file
54
backend/internal/app/certificate_task_cancellation_test.go
Normal file
@ -0,0 +1,54 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
|
||||
)
|
||||
|
||||
func TestCertificateCancellationPreservesDeploymentOutcome(t *testing.T) {
|
||||
for _, scenario := range []struct {
|
||||
name string
|
||||
report deployment.Report
|
||||
expected string
|
||||
}{
|
||||
{"partial", deployment.Report{Status: "partial", Total: 2, Success: 1, Failed: 1}, "partial_success"},
|
||||
{"recovery_unconfirmed", deployment.Report{Status: "failed", RecoveryStatus: "unconfirmed", Total: 1, Failed: 1}, "failed"},
|
||||
{"recovery_failed", deployment.Report{Status: "failed", RecoveryStatus: "failed", Total: 1, Failed: 1}, "failed"},
|
||||
{"restored", deployment.Report{Status: "failed", RecoveryStatus: "success", Total: 1, Failed: 1}, "cancelled"},
|
||||
} {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
w, a := newCertificateTaskFixture(t)
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
scenario.report.Results = []deployment.Result{{ServerID: "cancel-target", Status: "failed", Message: "secret-output"}}
|
||||
if scenario.report.Success > 0 {
|
||||
scenario.report.Results = append(scenario.report.Results, deployment.Result{ServerID: "completed-target", Status: "success"})
|
||||
}
|
||||
calls := 0
|
||||
_, err := w.run(ctx, "task-domain", "renewal_run", "certificate_issue", 3, time.Nanosecond,
|
||||
func(context.Context, string) (CertificateWorkflowResult, error) {
|
||||
calls++
|
||||
cancel()
|
||||
return CertificateWorkflowResult{Deployment: scenario.report}, context.Canceled
|
||||
})
|
||||
if !errors.Is(err, context.Canceled) || calls != 1 {
|
||||
t.Fatalf("取消后仍重试或丢失原始取消原因:%d %v", calls, err)
|
||||
}
|
||||
task := assertCertificateTask(t, a, scenario.expected, scenario.expected)
|
||||
results, err := a.Tasks.ListTaskDeploymentResults(t.Context(), task.ID)
|
||||
if err != nil || len(results) != len(scenario.report.Results) {
|
||||
t.Fatalf("取消后必须保留实际部署结果:%+v %v", results, err)
|
||||
}
|
||||
for _, result := range results {
|
||||
if strings.Contains(result.Message, "secret-output") {
|
||||
t.Fatal("取消后的部署结果泄露原始远端输出")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -152,10 +152,11 @@ func (w *certificateTaskWorkflow) run(ctx context.Context, id, taskType, stage s
|
||||
})
|
||||
result, resultErr = execute(issueCtx, id)
|
||||
cancel()
|
||||
if recordErr := w.tasks.RecordTaskDeploymentResults(taskCtx, task.ID, attempt.ID, number, taskDeploymentResults(result.Deployment)); recordErr != nil {
|
||||
// 取消只停止外部操作,结果保存仍使用独立且有限时的收尾上下文。
|
||||
finishCtx, finishCancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
|
||||
if recordErr := w.tasks.RecordTaskDeploymentResults(finishCtx, task.ID, attempt.ID, number, taskDeploymentResults(result.Deployment)); recordErr != nil {
|
||||
resultErr = errors.Join(resultErr, recordErr)
|
||||
}
|
||||
finishCtx, finishCancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
|
||||
finished := time.Now().UTC()
|
||||
status, message := certificateTaskOutcome(result, resultErr)
|
||||
recordErr := w.tasks.FinishAttempt(finishCtx, attempt.ID, status, message, &finished)
|
||||
@ -196,16 +197,17 @@ func taskDeploymentResults(report deployment.Report) []repository.TaskDeployment
|
||||
}
|
||||
|
||||
func certificateTaskOutcome(result CertificateWorkflowResult, err error) (string, string) {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return "cancelled", "\u8bc1\u4e66\u5de5\u4f5c\u6d41\u5df2\u53d6\u6d88"
|
||||
}
|
||||
if result.Deployment.Status == "partial" {
|
||||
return "partial_success", "\u8bc1\u4e66\u5de5\u4f5c\u6d41\u5df2\u5b8c\u6210\u90e8\u5206\u90e8\u7f72"
|
||||
return "partial_success", "证书工作流已完成部分部署"
|
||||
}
|
||||
// 未确认或失败的恢复不能被取消意图掩盖,部分成功也必须保留真实状态。
|
||||
if errors.Is(err, context.Canceled) && result.Deployment.RecoveryStatus != "unconfirmed" && result.Deployment.RecoveryStatus != "failed" {
|
||||
return "cancelled", "证书工作流已取消"
|
||||
}
|
||||
if err != nil {
|
||||
return "failed", "\u8bc1\u4e66\u5de5\u4f5c\u6d41\u672a\u5b8c\u6210\uff0c\u8bf7\u68c0\u67e5\u53d7\u4fdd\u62a4\u7684\u670d\u52a1\u65e5\u5fd7"
|
||||
return "failed", "证书工作流未完成,请检查受保护的服务日志"
|
||||
}
|
||||
return "success", "\u8bc1\u4e66\u5de5\u4f5c\u6d41\u6267\u884c\u5b8c\u6210"
|
||||
return "success", "证书工作流执行完成"
|
||||
}
|
||||
|
||||
var _ certificate.CertificateIssuer = (*certificateTaskWorkflow)(nil)
|
||||
|
||||
@ -62,7 +62,7 @@ var (
|
||||
mysqlIntegerWidthPattern = regexp.MustCompile(`^int\([0-9]+\)$`)
|
||||
)
|
||||
|
||||
// 只解析这三份内嵌迁移使用的语法;出现未知定义时拒绝迁移,避免漏检结构。
|
||||
// 只解析部署与报告内嵌迁移使用的语法;出现未知定义时拒绝迁移,避免漏检结构。
|
||||
func parseMySQLDeploymentShape(statement string) (mysqlDeploymentShape, error) {
|
||||
var shape mysqlDeploymentShape
|
||||
lines := strings.Split(strings.TrimSpace(statement), "\n")
|
||||
|
||||
@ -0,0 +1,73 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMySQLRealTaskReportMigrationRecovery(t *testing.T) {
|
||||
f := startRealMySQL(t)
|
||||
baseline := f.database(t)
|
||||
if err := Migrate(t.Context(), baseline.DB, "mysql"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectedSchema := mysqlSchemaSnapshot(t, baseline.DB)
|
||||
content, err := json.Marshal(map[string]string{"message": strings.Repeat("部署报告", 20000)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, boundary := range []string{"before_create", "after_create", "with_report"} {
|
||||
t.Run(boundary, func(t *testing.T) {
|
||||
db := f.database(t)
|
||||
mysqlPrepareVersion(t, db.DB, 19)
|
||||
if boundary != "before_create" {
|
||||
mysqlDeploymentExec(t, db.DB, mysqlDeploymentStatements(t, 20)[0])
|
||||
}
|
||||
if boundary == "with_report" {
|
||||
if _, err := db.DB.ExecContext(t.Context(), `INSERT INTO task_deployment_reports
|
||||
(task_id, attempt_id, attempt_number, report_json) VALUES ('report-task', 'report-attempt', 1, ?)`, string(content)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
mysqlAssertUpgrade(t, db.DB, expectedSchema)
|
||||
if boundary == "with_report" {
|
||||
var stored string
|
||||
if err := db.DB.QueryRowContext(t.Context(), `SELECT report_json FROM task_deployment_reports
|
||||
WHERE task_id = 'report-task' AND attempt_id = 'report-attempt'`).Scan(&stored); err != nil || stored != string(content) {
|
||||
t.Fatalf("恢复迁移必须保留超过普通文本字段容量的中文报告:%v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, invalid := range []struct {
|
||||
name string
|
||||
statement string
|
||||
}{
|
||||
{"short_report", "ALTER TABLE task_deployment_reports MODIFY COLUMN report_json TEXT NOT NULL"},
|
||||
{"wrong_primary_order", "ALTER TABLE task_deployment_reports DROP PRIMARY KEY, ADD PRIMARY KEY (attempt_id, task_id)"},
|
||||
{"nullable_attempt", "ALTER TABLE task_deployment_reports MODIFY COLUMN attempt_number INT NULL"},
|
||||
{"wrong_engine", "ALTER TABLE task_deployment_reports ENGINE=MyISAM"},
|
||||
{"extra_column", "ALTER TABLE task_deployment_reports ADD COLUMN unexpected INT NOT NULL DEFAULT 0"},
|
||||
} {
|
||||
t.Run(invalid.name, func(t *testing.T) {
|
||||
db := f.database(t)
|
||||
mysqlPrepareVersion(t, db.DB, 19)
|
||||
mysqlDeploymentExec(t, db.DB, mysqlDeploymentStatements(t, 20)[0])
|
||||
mysqlDeploymentExec(t, db.DB, `INSERT INTO task_deployment_reports
|
||||
(task_id, attempt_id, attempt_number, report_json) VALUES ('kept-task', 'kept-attempt', 1, '{"status":"failed"}')`)
|
||||
mysqlDeploymentExec(t, db.DB, invalid.statement)
|
||||
before := fmt.Sprint(mysqlSchemaSnapshot(t, db.DB))
|
||||
assertMySQLDeploymentMigrationRejected(t, db.DB, 19)
|
||||
if after := fmt.Sprint(mysqlSchemaSnapshot(t, db.DB)); after != before {
|
||||
t.Fatal("报告表冲突不得被自动覆盖或修复")
|
||||
}
|
||||
var stored string
|
||||
if err := db.DB.QueryRowContext(t.Context(), `SELECT report_json FROM task_deployment_reports
|
||||
WHERE task_id = 'kept-task' AND attempt_id = 'kept-attempt'`).Scan(&stored); err != nil || stored != `{"status":"failed"}` {
|
||||
t.Fatalf("拒绝迁移时不得修改已有报告:%v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,16 @@
|
||||
# Linux 生产流程测试记录
|
||||
|
||||
## 2026年10月2日18:11 后台取消与 MySQL 报告迁移回归(未部署)
|
||||
|
||||
- [x] 新增原生 Go 回归 TestCertificateCancellationPreservesDeploymentOutcome;background-cancel-before.log 中四个场景全部失败,BACKGROUND_CANCEL_BEFORE_EXIT=1。三个场景被错误标为 cancelled,已恢复场景未保存逐服务器结果。
|
||||
- [x] 修复为部分成功优先、恢复未确认/失败不被取消掩盖,并使用独立十秒收尾上下文保存实际结果。background-cancel-after.log 重复二十次通过,BACKGROUND_CANCEL_AFTER_EXIT=0,耗时 17.847 秒。普通重复回归不替代竞争检测。
|
||||
- [x] 新增原生 Go 实库测试 TestMySQLRealTaskReportMigrationRecovery;使用系统已有 /usr/sbin/mysqld 8.0.46,不安装额外数据库,不接受已有实例连接串。运行前检查磁盘余量;新实例仅监听 127.0.0.1:3887,严格 TLS,测试使用随机凭据。
|
||||
- [x] report-mysql8.log:三条迁移恢复路径与五类冲突全部通过,REPORT_MYSQL8_EXIT=0,耗时 49.100 秒。覆盖超过普通 TEXT 容量的中文报告保持、主键顺序、可空字段、存储引擎及额外字段;冲突重复拒绝且结构和报告不被覆盖。
|
||||
- [x] 实库产物保留于 /var/tmp/scert-report-mysql-20261002-1808/scert-mysql-integration-1850050069;没有连接或清理线上业务数据库,没有创建辅助验收脚本。
|
||||
- [~] background-full.log 与 background-vet.log 为新增后台修复后的全量回归,当前尚待收集最终结果。
|
||||
- [~] 第一台从干净源码归档 4e8d509 启动受限竞争检测,归档 SHA256 为 401d480814aa3f777fcb51b36b7837ced08ba6964475b2d996066167da48eec8。独立单元 scert-report-race-20261002-1801,MemoryMax=512M、MemoryHigh=384M、MemorySwapMax=0、CPUQuota=50%、RuntimeMaxSec=600;限定任务与 HTTP 异步专项,不扩展成全项目竞争检测通过。
|
||||
- [!] 当前 MySQL 5.7、PostgreSQL 新迁移、后台全报告原子化、其他长操作、关停 502、通知重置及统一生产门禁仍待完成;本批未正式部署。
|
||||
|
||||
## 2026年10月2日17:56 完整报告与审计事务回归(未部署)
|
||||
|
||||
- [x] 源码 4e8d509 已推送;第二台隔离目录 /var/tmp/scert-async-20261002-1556/source 执行,不修改正式安装目录或线上数据库。
|
||||
|
||||
@ -1,15 +1,18 @@
|
||||
# Scert 整改任务执行清单
|
||||
|
||||
## 2026年10月2日17:56 当前执行入口
|
||||
## 2026年10月2日18:11 当前执行入口
|
||||
|
||||
本节为当前状态,下方时间段保留历史过程。需求沿用已确认的 Linux 全清单:先完成开发和自动回归,再统一正式部署;不清线上数据库,不以测试脚本替代产品验收。
|
||||
|
||||
- [x] 完整报告与完成审计原子化已保存并推送为 4e8d509;任务、尝试、完整报告、逐目标结果和完成审计在 HTTP 异步收尾路径共用事务,任何一项失败全部回滚。
|
||||
- [x] 第二台隔离源码第三轮仓储、HTTP、数据库关联回归全部通过,REPORT_THIRD_EXIT=0。前两轮失败日志保留;不将修复前失败抹去。
|
||||
- [x] 最新前端 256 项全部通过,零失败、零跳过;生产构建通过。覆盖页面刷新读取完整验证与恢复报告、重试报告隔离及旧响应兼容。
|
||||
- [x] 最新全后端测试与 go vet 在第二台串行、低资源运行通过,REPORT_FULL_EXIT=0、REPORT_VET_EXIT=0;未部署本批代码。
|
||||
- [~] 核查自动续期、导入、恢复等其他工作流:已发现取消错误优先于部分成功和恢复未确认,可能掩盖真实结果;下一步新增负向回归并修复。当前原子化不能扩大为全项目所有工作流已经统一。
|
||||
- [ ] 补齐新报告迁移的实库恢复、结构冲突及旧数据保持回归;三库迁移文件齐全不代表三库生产验收完成。
|
||||
- [x] 完整报告批次 4e8d509 的全后端测试与 go vet 在第二台串行、低资源运行通过,REPORT_FULL_EXIT=0、REPORT_VET_EXIT=0;未部署本批代码。
|
||||
- [x] 后台工作流四个取消场景在旧代码全部复现失败:部分成功、恢复未确认、恢复失败被取消状态掩盖,已恢复场景的逐服务器结果丢失。修复为保留真实结果并用有限时、独立于取消的上下文保存结果;四场景重复二十次通过,BACKGROUND_CANCEL_AFTER_EXIT=0。
|
||||
- [~] 上述后台修复的最新全后端回归与静态检查运行中;后台报告、尝试和审计尚未全部统一到同一事务,不能按 HTTP 路径的结果标记整项关闭。
|
||||
- [x] 新增第 20 号报告迁移实库回归:第二台独立 MySQL 8.0.46、回环监听、严格 TLS,建表前/建表后/已有大中文报告三条恢复路径,以及五类结构冲突全部通过,REPORT_MYSQL8_EXIT=0。
|
||||
- [ ] 新报告迁移的 MySQL 5.7、PostgreSQL 当前版本实库复核;三库生产 Web 门禁仍未完成。
|
||||
- [~] 第一台启动独立受限竞争检测:内存上限 512MB、CPU 上限 50%、最长十分钟,不使用此前无限制负载,尚无通过结论。
|
||||
- [!] 竞争检测、其他长操作异步化、关停与重启 502、历史恢复证据、系统通知重置、三库首次 Web、备份恢复及最终验收仍未关闭。
|
||||
|
||||
校正:17:30 的“完成审计尚未与上述事务统一”属于当时状态,现已实现且关联回归通过;17:11 的推送超时和竞争测试运行态也不是当前状态,推送已成功,先前竞争检测中断退出 130。
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
# Scert 整改、合并与优化清单
|
||||
|
||||
## 2026年10月2日17:56 最新开发进度入口
|
||||
## 2026年10月2日18:11 最新开发进度入口
|
||||
|
||||
- [x] P0-10/P1-04 子项:HTTP 异步任务完整报告已持久化,终态、尝试、逐服务器结果与完成审计共用事务;前端按最新尝试读取验证和恢复状态,旧记录不伪造结论。源码 4e8d509 已推送。
|
||||
- [x] 最新仓储、HTTP、数据库关联回归通过;前端 256 项通过、生产构建通过。第一、第二轮失败与修复经过保留在 Linux 测试记录中。
|
||||
- [x] 完整报告批次的全后端回归及静态检查已通过,REPORT_FULL_EXIT=0、REPORT_VET_EXIT=0;自动续期等其他工作流仍需单独核查,不把 HTTP 路径原子化扩大为全项目完成。
|
||||
- [x] 后台取消问题已用四个失败用例复现并修复:保留部分成功及未确认恢复状态,取消后仍保存实际逐服务器结果;重复二十次通过,最新全量回归进行中。
|
||||
- [x] 第 20 号报告迁移在独立 MySQL 8.0.46 严格 TLS 实例通过八个恢复/冲突场景,保留已有大中文报告及冲突现场;不是三库完整生产 Web 验收。
|
||||
- [ ] 新迁移实库恢复、其他长操作、关停 502、系统通知重置、三数据库首次 Web、备份恢复及统一生产复审继续执行。
|
||||
- [!] 本批未部署、未清库;竞争检测无通过证据,原 P0/P1/P2/P3 清单不缩减,Windows 按要求不在本轮处理。下方带时间的旧状态仅作历史证据,不据此重复报告旧阻塞。
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user