From cf3bc7aaa56fd066f34f7e8b63c976ea2f07a308 Mon Sep 17 00:00:00 2001 From: xuans Date: Fri, 2 Oct 2026 18:17:28 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=9D=E5=AD=98=E5=90=8E=E5=8F=B0=E5=8F=96?= =?UTF-8?q?=E6=B6=88=E7=BB=93=E6=9E=9C=E4=BF=AE=E5=A4=8D=E5=92=8C=E6=8A=A5?= =?UTF-8?q?=E5=91=8A=E8=BF=81=E7=A7=BB=E5=AE=9E=E5=BA=93=E5=9B=9E=E5=BD=92?= =?UTF-8?q?=E5=9F=BA=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/certificate_task_cancellation_test.go | 54 ++++++++++++++ backend/internal/app/certificate_tasks.go | 18 +++-- .../database/mysql_deployment_migration.go | 2 +- .../mysql_task_report_migration_test.go | 73 +++++++++++++++++++ doc/Linux生产流程测试记录.md | 11 +++ doc/Scert整改任务执行清单.md | 11 ++- doc/Scert整改合并优化清单.md | 4 +- 7 files changed, 159 insertions(+), 14 deletions(-) create mode 100644 backend/internal/app/certificate_task_cancellation_test.go create mode 100644 backend/internal/database/mysql_task_report_migration_test.go diff --git a/backend/internal/app/certificate_task_cancellation_test.go b/backend/internal/app/certificate_task_cancellation_test.go new file mode 100644 index 0000000..7e86fa7 --- /dev/null +++ b/backend/internal/app/certificate_task_cancellation_test.go @@ -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("取消后的部署结果泄露原始远端输出") + } + } + }) + } +} diff --git a/backend/internal/app/certificate_tasks.go b/backend/internal/app/certificate_tasks.go index 8e7df6e..2ef239d 100644 --- a/backend/internal/app/certificate_tasks.go +++ b/backend/internal/app/certificate_tasks.go @@ -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) diff --git a/backend/internal/database/mysql_deployment_migration.go b/backend/internal/database/mysql_deployment_migration.go index 0ca15d3..92f0339 100644 --- a/backend/internal/database/mysql_deployment_migration.go +++ b/backend/internal/database/mysql_deployment_migration.go @@ -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") diff --git a/backend/internal/database/mysql_task_report_migration_test.go b/backend/internal/database/mysql_task_report_migration_test.go new file mode 100644 index 0000000..60af828 --- /dev/null +++ b/backend/internal/database/mysql_task_report_migration_test.go @@ -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) + } + }) + } +} diff --git a/doc/Linux生产流程测试记录.md b/doc/Linux生产流程测试记录.md index 59f9d24..bd405c8 100644 --- a/doc/Linux生产流程测试记录.md +++ b/doc/Linux生产流程测试记录.md @@ -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 执行,不修改正式安装目录或线上数据库。 diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index 8a534a5..5f14f73 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -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。 diff --git a/doc/Scert整改合并优化清单.md b/doc/Scert整改合并优化清单.md index 71e70b9..ac40720 100644 --- a/doc/Scert整改合并优化清单.md +++ b/doc/Scert整改合并优化清单.md @@ -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 按要求不在本轮处理。下方带时间的旧状态仅作历史证据,不据此重复报告旧阻塞。