修复已核验回滚事务终态并同步整改进度

This commit is contained in:
xuans 2026-10-01 10:46:30 +08:00
parent c96cd434a4
commit 8899f7af9e
5 changed files with 181 additions and 6 deletions

View File

@ -457,10 +457,11 @@ func (s *Service) deployOne(ctx context.Context, server Server, artifact Artifac
closed = true
}
status := TransactionStatusCommitted
if retain || result.Status != "success" {
status = TransactionStatusRecoveryRequired
} else if result.Message != "" && result.Status == "failed" {
if !retain && result.Status == "failed" && result.FailureCode == FailureRecoveryPending {
status = TransactionStatusRolledBack
result.Message = publicFailureMessages[FailureRecoveryRestored]
} else if retain || result.Status != "success" {
status = TransactionStatusRecoveryRequired
}
finishTransaction(status, result.Message)
return result

View File

@ -0,0 +1,122 @@
package deployment
import (
"context"
"errors"
"testing"
)
type failedUnlockClient struct{ *memoryClient }
func (c *failedUnlockClient) Unlock(context.Context, string) error {
return errors.New("unlock failed")
}
func TestVerifiedRollbackJournalRetainsFailedFinalization(t *testing.T) {
for _, scenario := range []struct {
name string
configure func(*memoryClient) Client
wantCode FailureCode
}{
{"unlock", func(c *memoryClient) Client { return &failedUnlockClient{c} }, FailureLockRelease},
{"restore", func(c *memoryClient) Client { c.restoreError = true; return c }, FailureRollback},
} {
t.Run(scenario.name, func(t *testing.T) {
client, server := newMemoryClient(), testServer("failed-finalization")
seedVerifiedFiles(client, server)
journal := &memoryTransactionJournal{}
service := NewServiceWithJournal(fixedClientFactory{scenario.configure(client)}, journal)
ctx := WithTransactionContext(t.Context(), TransactionContext{TaskID: "task", AttemptID: "attempt", AttemptNumber: 1})
report, err := service.DeployVerified(ctx, []Target{{Server: server}}, testArtifact(t), Verification{
Verify: func(context.Context) error { return errors.New("verification failed") },
VerifyRecovery: func(context.Context) error { return nil },
})
if err == nil || report.RecoveryStatus != "failed" || report.Results[0].FailureCode != scenario.wantCode {
t.Fatalf("unexpected failure report: %+v, %v", report, err)
}
if len(journal.finished) != 1 || journal.finished[0].status != TransactionStatusRecoveryRequired {
t.Fatalf("failed finalization must retain recovery state: %+v", journal.finished)
}
assertVerifiedLocks(t, client, server, true)
})
}
}
func TestVerifiedPartialFailurePersistsRolledBackTarget(t *testing.T) {
client, first := newMemoryClient(), testServer("first")
second := testServer("second")
original := seedVerifiedFiles(client, first)
journal := &memoryTransactionJournal{}
service := NewServiceWithJournal(memoryFactory{
clients: map[string]*memoryClient{first.ID: client},
errors: map[string]error{second.ID: errors.New("connection failed")},
}, journal)
ctx := WithTransactionContext(t.Context(), TransactionContext{TaskID: "task", AttemptID: "attempt", AttemptNumber: 1})
report, err := service.DeployVerified(ctx, []Target{{Server: first}, {Server: second}}, testArtifact(t), Verification{
Verify: func(context.Context) error { t.Fatal("partial preparation must not verify deployment"); return nil },
VerifyRecovery: func(context.Context) error { assertVerifiedFiles(t, client, original); return nil },
})
if err == nil || report.Status != "failed" || report.RecoveryStatus != "success" {
t.Fatalf("unexpected partial failure report: %+v, %v", report, err)
}
if len(journal.finished) != 1 || journal.finished[0].status != TransactionStatusRolledBack {
t.Fatalf("restored target must be finalized as rolled back: %+v", journal.finished)
}
assertVerifiedLocks(t, client, first, false)
}
func TestVerifiedRollbackJournalMatchesRecoveryEvidence(t *testing.T) {
for _, scenario := range []struct {
name string
recoveryError error
wantStatus string
wantRecovery string
wantLocks bool
}{
{"verified", nil, TransactionStatusRolledBack, "success", false},
{"unverified", errors.New("recovery verification failed"), TransactionStatusRecoveryRequired, "unconfirmed", true},
} {
t.Run(scenario.name, func(t *testing.T) {
servers := []Server{testServer("first"), testServer("second")}
clients := map[string]*memoryClient{}
originals := map[string]map[string]memoryFile{}
for _, server := range servers {
clients[server.ID] = newMemoryClient()
originals[server.ID] = seedVerifiedFiles(clients[server.ID], server)
}
journal := &memoryTransactionJournal{}
service := NewServiceWithJournal(memoryFactory{clients: clients}, journal)
ctx := WithTransactionContext(t.Context(), TransactionContext{
TaskID: "task", AttemptID: "attempt", AttemptNumber: 1,
})
verificationError := errors.New("deployment verification failed")
report, err := service.DeployVerified(ctx, []Target{{Server: servers[0]}, {Server: servers[1]}}, testArtifact(t), Verification{
Verify: func(context.Context) error { return verificationError },
VerifyRecovery: func(context.Context) error {
for _, server := range servers {
assertVerifiedFiles(t, clients[server.ID], originals[server.ID])
assertVerifiedLocks(t, clients[server.ID], server, true)
}
return scenario.recoveryError
},
})
if !errors.Is(err, verificationError) || report.Status != "failed" || report.RecoveryStatus != scenario.wantRecovery {
t.Fatalf("unexpected report: %+v, error: %v", report, err)
}
if len(journal.finished) != len(servers) {
t.Fatalf("unexpected journal completion count: %d", len(journal.finished))
}
for _, record := range journal.finished {
if record.status != scenario.wantStatus {
t.Errorf("transaction %s: got %s, want %s", record.id, record.status, scenario.wantStatus)
}
if record.status == TransactionStatusRolledBack && record.message != publicFailureMessages[FailureRecoveryRestored] {
t.Errorf("rolled back transaction has stale message: %s", record.message)
}
}
for _, server := range servers {
assertVerifiedLocks(t, clients[server.ID], server, scenario.wantLocks)
}
})
}
}

View File

@ -1,4 +1,24 @@
# Linux 生产流程测试记录
## 当前核查与开发记录(2026年10月1日,北京时间)
本批本地验证收尾:后端 `go test ./...`、`go vet ./...`、`go build ./...` 均通过;部署、应用、仓库三个包的竞态回归分别约八秒、五十九秒、三十二秒,全部退出零。条件跳过的外部数据库和 SSH 场景没有据此宣称通过,正式生产验收仍未完成。
### 只读现场核查
- 10:24 使用密码 SSH 并启用已登记主机指纹严格校验连接第二台服务器;Scert、存储 socket、Nginx 均为运行状态。
- 使用 SQLite 只读连接确认:最新任务仍为失败;两笔事务 `20260930-231313.144123222-ce71b1dfe14e`、`20260930-231310.231207974-5bf9d1e37542` 仍为需要恢复,提示旧文件已经恢复但等待站点恢复核验。
- 目标域名目录仍有三个部署锁;中断测试证据目录也有三个锁。本批没有清库、重启服务、删除文件或改变这些记录。本次只读诊断不是清库首次生产流程验收。
- 上轮报告中的两个域名、两台服务器严格 HTTPS 通过,属于当时站点状态证据;不能据此把当前失败任务判为成功。本批没有重新执行该四次 HTTPS 检查。
### 确认的代码缺陷与修复边界
- 新回归在修改前稳定失败:双目标恢复核验已经通过,事务终态却为需要恢复而非已回滚。
- 根因是终态判断先匹配所有失败结果,随后已回滚分支永远不可达。修改后,只有恢复核验成功且锁释放成功才记录已回滚,并复用现有中文恢复完成提示;部署任务仍保持失败。
- 补充双目标成功恢复、恢复核验失败、文件回滚失败、锁释放失败、部分目标连接失败场景。没有创建辅助验收脚本。
- 这不是旧事务的自动修复:旧数据不重写,现场锁不移除。崩溃后的站点基线验证、锁归属证明和历史现场接管仍需继续整改与实机验证。
- 修改前检查点为 `c96cd43`;第一次推送认证失败,第二次推送成功。最终测试、构建与部署状态以本批后续收尾记录为准。
## 最新任务状态(2026-09-30,双服务器候选部署完成)
- 本轮代码提交 `07229ad` 已推送;正式 Linux amd64 候选包 `build/acceptance-20260930-smail/Scert-20260930-V0.1.0-linux-amd64.tar.gz` 已构建并完成归档校验。
@ -1094,4 +1114,4 @@ git diff --check
- [x] 已移除远端 HTTPS 校验的跳过证书验证配置,改用受信任根证书进行严格 TLS 校验。
- [x] `go test ./...`、`go vet ./...`、`git diff --check` 通过;新增指定地址与域名 SNI 回归测试。
- [~] 尚未重新部署本轮最新候选包;因此本节代码结果不能替代两台服务器的实机业务验收。
- [!] 第一台服务器仍需确认并通过真实 Nginx 站点配置、证书加载和独立 HTTPS 指纹校验;完整生产门禁继续未通过。
- [!] 第一台服务器仍需确认并通过真实 Nginx 站点配置、证书加载和独立 HTTPS 指纹校验;完整生产门禁继续未通过。

View File

@ -1,4 +1,21 @@
# Scert 整改任务执行清单
## 当前有效任务看板(2026年10月1日,回滚事务终态整改)
本批收尾记录:后端 `go test ./...`、`go vet ./...`、`go build ./...` 通过;`go test -race ./internal/deployment ./internal/app ./internal/repository -count=1` 三个包通过。条件跳过用例不算通过,尚未部署,不提升生产门禁。
需求确认:沿用原始四十四项要求,继续完整生产整改;先修复明确代码缺陷,再构建候选并执行生产流程。运维使用密码登录,产品保留自动公钥部署能力。以下为当前状态,下方快照只作历史证据。
- [x] T5-1:以双目标回归复现恢复核验成功但事务写为需要恢复,修复不可达的已回滚分支。
- [x] T5-2:已核验恢复只将事务记为已回滚,不将原失败任务改为成功;恢复失败或锁释放失败仍保留需要恢复状态。
- [x] T5-3:补充双目标恢复核验、恢复失败、锁释放失败和部分目标失败的持久化接口回归。
- [x] T5-4:后端全量、静态检查、构建及专项竞态检查;最终命令结果见本批收尾记录。
- [ ] T5-5:规范构建新候选;旧部署事务先核对归属与恢复证据,不人工改库,不直接清理现场。
- [ ] T5-6:正式页面多域名多服务器部署、负向恢复、锁收尾及重试幂等实机复测。
- [ ] T4-3:清库后的首次初始化、系统 SMTP、超级管理员密码重置和新密码登录。
- [ ] T7:三数据库、双平台服务、备份恢复、公网 DNS 与 ACME、最终生产门禁。
- [!] 本批仅代码与回归整改,线上版本未变。整体生产验收未放行;P0-03 的历史局部复审不能替代 P0-10 与其他门禁。
## 本轮任务看板(2026-09-30,最新候选已完成双服务器基础部署)
- [x] T2 子项:识别 Smail `notify:` 系统通知账号,固定系统通知发件身份并禁止 HTTP 通道发送。
@ -1429,4 +1446,4 @@ P0-05 从 `[!]` 调整为 `[~]`,并未标记完成;P0-04 剩余门禁和全
- [~] T5 逐台验证一域名多服务器、单服务器多域名、真实证书推送、Nginx 校验/重载、失败回滚和重试幂等;第一台服务器的真实 Nginx 站点配置仍需补齐并复核。
- [x] T6 整改主清单、执行清单和 Linux 测试记录已追加本轮代码证据。
- [ ] T7 三数据库、Windows 服务、清库初始化、公网 HTTPS/DNS/ACME、备份恢复和最终上线结论仍未完成。
- [!] 总体验收继续保持 **P0-03:完成,待复审**,不能以自动化测试或页面提示替代生产验收。
- [!] 总体验收继续保持 **P0-03:完成,待复审**,不能以自动化测试或页面提示替代生产验收。

View File

@ -1,4 +1,19 @@
# Scert 整改、合并与优化清单
## 当前有效进度(2026年10月1日,回滚事务终态整改)
本批验证:后端全量测试、静态检查、构建以及部署、应用、仓库三个包的竞态测试均退出零;外部实库条件跳过项不计入验收。正式候选构建与实机复测尚未完成。
本节优先于下方历史快照。原始四十四项范围和生产门禁不缩减;不再使用没有逐项统计依据的完成百分比。
- [x] P0-10 子项:复现并修复恢复核验成功后事务仍记为需要恢复的问题。原判断提前捕获所有失败结果,导致已回滚分支不可达;现在仅在文件恢复、服务重载、站点恢复核验与锁释放全部成功时记录已回滚,原部署任务仍为失败。
- [x] 增加双目标已恢复、恢复核验失败、文件恢复失败、锁释放失败及部分目标连接失败的日志终态回归;不把隔离测试当作实机验收。
- [~] 当前修复尚未部署;旧失败任务及需要恢复的事务保持原样,禁止直接修改数据库或删除锁伪造验收通过。
- [~] P0-10 整项仍缺进程崩溃后的可信恢复、历史现场接管、多目标负向实机及重试幂等证据。
- [x] 口径纠正:运维连接继续使用密码;产品按用户后续要求自动部署公钥并使用托管密钥,不因数据库认证方式为私钥就判为缺陷。
- [x] P0-03 已有源码与产物边界局部复审记录,不再以该项表示全部上线阻塞;不扩大历史局部复审范围。
- [ ] 三数据库完整矩阵、双平台服务、备份恢复、清库首次流程、系统 SMTP 与密码重置及最终生产门禁继续待验收。
## 本轮整改进度(2026-09-30,最新候选已完成双服务器基础部署)
需求确认:继续按明日上线目标推进,但不缩减生产门禁;系统通知使用 Smail `notify:` SMTP 账号时,只允许通过 SMTP 发信,不使用普通 HTTP 发信接口,不记录真实凭据。
@ -2063,4 +2078,4 @@ P0-11 生产验收门禁
- [x] 已完成:本轮代码提交为 `840d48d`、`1164d50`、`5ef8e54`,均已尝试推送远程仓库。
- [~] 进行中:重新构建并部署最新候选包,逐台复核两台服务器的真实 Nginx 站点、证书指纹和失败恢复。
- [ ] 未开始:三数据库生产矩阵、Windows 服务、公网 DNS/ACME、完整备份恢复及最终生产门禁。
- [!] 总体验收仍为 **P0-03:完成,待复审**,本轮不得新增 `[✓]`。
- [!] 总体验收仍为 **P0-03:完成,待复审**,本轮不得新增 `[✓]`。