保存人工处置持久化与实库回归检查点

This commit is contained in:
xuans 2026-10-03 19:49:36 +08:00
parent ed0c97b957
commit 17eb2fc295
25 changed files with 1070 additions and 3 deletions

View File

@ -22,6 +22,9 @@ import (
//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
//go:embed migrations/sqlite/0021_task_validation_results.sql migrations/mysql/0021_task_validation_results.sql migrations/postgres/0021_task_validation_results.sql
//go:embed migrations/sqlite/0022_recovery_dispositions.sql migrations/mysql/0022_recovery_dispositions.sql migrations/postgres/0022_recovery_dispositions.sql
//go:embed migrations/sqlite/0023_recovery_claims.sql migrations/mysql/0023_recovery_claims.sql migrations/postgres/0023_recovery_claims.sql
//go:embed migrations/sqlite/0024_recovery_events.sql migrations/mysql/0024_recovery_events.sql migrations/postgres/0024_recovery_events.sql
var migrationFiles embed.FS
func Migrate(ctx context.Context, db *sql.DB, driver string) error {
@ -46,7 +49,7 @@ func Migrate(ctx context.Context, db *sql.DB, driver string) error {
return nil
}
const latestMigrationVersion = 21
const latestMigrationVersion = 24
// currentMigrationVersion 在执行业务迁移前拒绝新版数据库及不连续的迁移历史。
func currentMigrationVersion(ctx context.Context, db *sql.DB) (int, error) {
@ -86,7 +89,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) || version == 20 || version == 21 {
if (version >= 15 && version <= 17) || (version >= 20 && version <= 24) {
return applyMySQLDeploymentMigration(ctx, db, version, statements)
}
if version == 14 {
@ -243,6 +246,12 @@ func migrationName(version int) string {
return "task_deployment_reports"
case 21:
return "task_validation_results"
case 22:
return "recovery_dispositions"
case 23:
return "recovery_claims"
case 24:
return "recovery_events"
default:
return "unknown"
}

View File

@ -0,0 +1,17 @@
CREATE TABLE recovery_dispositions (
id VARCHAR(64) NOT NULL,
transaction_id VARCHAR(160) NOT NULL,
server_id VARCHAR(160) NOT NULL,
operator_id VARCHAR(160) NOT NULL,
action VARCHAR(32) NOT NULL,
request_key VARCHAR(64) NOT NULL,
record_digest VARCHAR(64) NOT NULL,
server_digest VARCHAR(64) NOT NULL,
observation_digest VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
result_code VARCHAR(64) NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_recovery_request (request_key)
);

View File

@ -0,0 +1,7 @@
CREATE TABLE recovery_claims (
transaction_id VARCHAR(160) NOT NULL,
disposition_id VARCHAR(64) NOT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (transaction_id),
UNIQUE KEY uq_recovery_claim (disposition_id)
);

View File

@ -0,0 +1,7 @@
CREATE TABLE recovery_events (
disposition_id VARCHAR(64) NOT NULL,
event_type VARCHAR(32) NOT NULL,
result_code VARCHAR(64) NOT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (disposition_id, event_type)
);

View File

@ -0,0 +1,15 @@
CREATE TABLE recovery_dispositions (
id TEXT NOT NULL PRIMARY KEY,
transaction_id TEXT NOT NULL,
server_id TEXT NOT NULL,
operator_id TEXT NOT NULL,
action TEXT NOT NULL,
request_key TEXT NOT NULL UNIQUE,
record_digest TEXT NOT NULL,
server_digest TEXT NOT NULL,
observation_digest TEXT NOT NULL,
status TEXT NOT NULL,
result_code TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);

View File

@ -0,0 +1,5 @@
CREATE TABLE recovery_claims (
transaction_id TEXT NOT NULL PRIMARY KEY,
disposition_id TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL
);

View File

@ -0,0 +1,7 @@
CREATE TABLE recovery_events (
disposition_id TEXT NOT NULL,
event_type TEXT NOT NULL,
result_code TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (disposition_id, event_type)
);

View File

@ -0,0 +1,15 @@
CREATE TABLE recovery_dispositions (
id TEXT NOT NULL PRIMARY KEY,
transaction_id TEXT NOT NULL,
server_id TEXT NOT NULL,
operator_id TEXT NOT NULL,
action TEXT NOT NULL,
request_key TEXT NOT NULL UNIQUE,
record_digest TEXT NOT NULL,
server_digest TEXT NOT NULL,
observation_digest TEXT NOT NULL,
status TEXT NOT NULL,
result_code TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);

View File

@ -0,0 +1,5 @@
CREATE TABLE recovery_claims (
transaction_id TEXT NOT NULL PRIMARY KEY,
disposition_id TEXT NOT NULL UNIQUE,
created_at DATETIME NOT NULL
);

View File

@ -0,0 +1,7 @@
CREATE TABLE recovery_events (
disposition_id TEXT NOT NULL,
event_type TEXT NOT NULL,
result_code TEXT NOT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (disposition_id, event_type)
);

View File

@ -8,7 +8,7 @@ import (
)
func TestMySQLDeploymentShapeDefinitions(t *testing.T) {
for _, version := range []int{15, 16, 17, 20, 21} {
for _, version := range []int{15, 16, 17, 20, 21, 22, 23, 24} {
statements := mysqlDeploymentStatements(t, version)
shape, err := parseMySQLDeploymentShape(statements[0])
if err != nil || len(shape.Columns) == 0 || len(shape.Indexes) == 0 {

View File

@ -0,0 +1,58 @@
package database
import (
"fmt"
"testing"
)
func TestMySQLRealRecoveryDispositionMigrationRecovery(t *testing.T) {
f := startRealMySQL(t)
baseline := f.database(t)
if err := Migrate(t.Context(), baseline.DB, "mysql"); err != nil {
t.Fatal(err)
}
expected := mysqlSchemaSnapshot(t, baseline.DB)
for _, item := range []struct {
version int
table string
seed string
conflict string
}{
{22, "recovery_dispositions", `INSERT INTO recovery_dispositions VALUES
('kept', 'transaction', 'server', 'unix:0', 'record_inspection', 'request', 'record', 'server', 'observation', 'running', '', NOW(), NOW())`,
`ALTER TABLE recovery_dispositions DROP INDEX uq_recovery_request`},
{23, "recovery_claims", `INSERT INTO recovery_claims VALUES ('transaction', 'kept', NOW())`,
`ALTER TABLE recovery_claims DROP PRIMARY KEY`},
{24, "recovery_events", `INSERT INTO recovery_events VALUES ('kept', 'started', '', NOW())`,
`ALTER TABLE recovery_events DROP PRIMARY KEY, ADD PRIMARY KEY (event_type, disposition_id)`},
} {
for _, boundary := range []string{"before_create", "after_create", "with_record", "conflict"} {
t.Run(fmt.Sprintf("%d/%s", item.version, boundary), func(t *testing.T) {
db := f.database(t)
mysqlPrepareVersion(t, db.DB, item.version-1)
if boundary != "before_create" {
mysqlDeploymentExec(t, db.DB, mysqlDeploymentStatements(t, item.version)[0])
}
if boundary == "with_record" || boundary == "conflict" {
mysqlDeploymentExec(t, db.DB, item.seed)
}
if boundary == "conflict" {
mysqlDeploymentExec(t, db.DB, item.conflict)
before := fmt.Sprint(mysqlSchemaSnapshot(t, db.DB))
assertMySQLDeploymentMigrationRejected(t, db.DB, item.version-1)
if after := fmt.Sprint(mysqlSchemaSnapshot(t, db.DB)); after != before {
t.Fatal("结构冲突不得自动修复或覆盖")
}
} else {
mysqlAssertUpgrade(t, db.DB, expected)
}
if boundary == "with_record" || boundary == "conflict" {
var count int
if err := db.DB.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM "+item.table).Scan(&count); err != nil || count != 1 {
t.Fatal("迁移或冲突拒绝丢失已有处置记录", err)
}
}
})
}
}
}

View File

@ -0,0 +1,114 @@
package deployment
import (
"context"
"encoding/hex"
"errors"
"strings"
"time"
)
var (
ErrRecoveryDispositionInvalid = errors.New("人工处置记录参数无效")
ErrRecoveryDispositionConflict = errors.New("人工处置请求与已有记录不一致")
ErrRecoveryDispositionBusy = errors.New("该事务已有未释放的人工处置占用")
ErrRecoveryDispositionUnavailable = errors.New("人工处置记录暂不可用,未授权执行远端动作")
)
// RecoveryDispositionIntent 仅保存绑定摘要;不能包含历史证据、凭据或确认令牌原文。
type RecoveryDispositionIntent struct {
ID string
TransactionID string
ServerID string
Operator string
Action RecoveryAction
RequestKey string
RecordDigest string
ServerDigest string
ObservationDigest string
}
type RecoveryDisposition struct {
Intent RecoveryDispositionIntent `json:"-"`
Status string `json:"status"`
ResultCode string `json:"result_code"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
const (
RecoveryResultObserved = "observed"
RecoveryResultCommitted = "committed"
RecoveryResultRolledBack = "rolled_back"
RecoveryResultRejected = "rejected"
RecoveryResultUncertain = "uncertain"
)
// RecoveryDispositionJournal 的开始成功且新建返回值为真,才允许执行器继续核验。
// 记录本身不是远端写入授权;执行器仍需一次性确认、完整互斥及动作专属核验。
type RecoveryDispositionJournal interface {
BeginRecoveryDisposition(context.Context, RecoveryDispositionIntent) (RecoveryDisposition, bool, error)
FinishRecoveryDisposition(context.Context, string, string) (RecoveryDisposition, error)
GetRecoveryDisposition(context.Context, string) (RecoveryDisposition, error)
HasRecoveryClaim(context.Context, string) (bool, error)
}
func (r RecoveryReview) DispositionIntent(id, requestKey string) (RecoveryDispositionIntent, error) {
if r.service == nil || r.service.now == nil || !r.service.now().Before(r.summary.ExpiresAt) {
return RecoveryDispositionIntent{}, ErrRecoveryReviewExpired
}
intent := RecoveryDispositionIntent{
ID: id, TransactionID: r.summary.TransactionID, ServerID: r.summary.ServerID,
Operator: r.operator, Action: r.summary.Action, RequestKey: requestKey,
RecordDigest: hex.EncodeToString(r.recordDigest[:]),
ServerDigest: hex.EncodeToString(r.serverDigest[:]),
ObservationDigest: hex.EncodeToString(r.observationDigest[:]),
}
return intent, intent.Validate()
}
func (i RecoveryDispositionIntent) Validate() error {
if !validInspectionID(i.TransactionID) || !validRecoverySummary(i.ServerID) ||
!validReviewOperator(i.Operator) || !validRecoveryAction(i.Action) {
return ErrRecoveryDispositionInvalid
}
for _, value := range []string{i.ID, i.RequestKey, i.RecordDigest, i.ServerDigest, i.ObservationDigest} {
if len(value) != 64 || value != strings.ToLower(value) || value == strings.Repeat("0", 64) {
return ErrRecoveryDispositionInvalid
}
if _, err := hex.DecodeString(value); err != nil {
return ErrRecoveryDispositionInvalid
}
}
return nil
}
// SameRequest 忽略重试时新生成的记录标识,但必须匹配原请求的全部绑定值。
func (i RecoveryDispositionIntent) SameRequest(other RecoveryDispositionIntent) bool {
i.ID, other.ID = "", ""
return i == other
}
func (i RecoveryDispositionIntent) MatchesRecord(record TransactionRecord) bool {
if record.ID != i.TransactionID || record.ServerID != i.ServerID ||
(record.Status != TransactionStatusPending && record.Status != TransactionStatusRecoveryRequired) ||
(i.Action != RecoveryActionRecord && record.Evidence == nil) {
return false
}
digest, err := reviewDigest(record)
return err == nil && hex.EncodeToString(digest[:]) == i.RecordDigest
}
func (i RecoveryDispositionIntent) AllowsResult(result string) bool {
switch result {
case RecoveryResultRejected, RecoveryResultUncertain:
return validRecoveryAction(i.Action)
case RecoveryResultObserved:
return i.Action == RecoveryActionRecord
case RecoveryResultCommitted:
return i.Action == RecoveryActionCommit
case RecoveryResultRolledBack:
return i.Action == RecoveryActionRollback
}
return false
}

View File

@ -0,0 +1,51 @@
package deployment
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
)
func TestRecoveryReviewDispositionIntent(t *testing.T) {
server, memory, record := recoveryPreflightFixture()
journal := &memoryTransactionJournal{pending: []TransactionRecord{record}}
service := NewServiceWithJournal(fixedClientFactory{&inspectionClient{memoryClient: memory}}, journal)
resolve := func(context.Context, string) (Server, error) { return server, nil }
review, err := service.PrepareRecoveryReview(t.Context(), record.ID, "unix:0", RecoveryActionCommit, resolve)
if err != nil {
t.Fatal(err)
}
intent, err := review.DispositionIntent(strings.Repeat("a", 64), strings.Repeat("b", 64))
if err != nil || intent.Validate() != nil || !intent.MatchesRecord(record) {
t.Fatal("review bindings not retained", err)
}
if !intent.AllowsResult(RecoveryResultCommitted) || intent.AllowsResult(RecoveryResultRolledBack) || intent.AllowsResult(RecoveryResultObserved) {
t.Fatal("action result isolation failed")
}
encoded, _ := json.Marshal(RecoveryDisposition{Intent: intent})
if strings.Contains(string(encoded), intent.RequestKey) || strings.Contains(string(encoded), intent.RecordDigest) || strings.Contains(string(encoded), "unix:0") {
t.Fatal("internal bindings exposed in public JSON")
}
for _, value := range []string{"", "token-secret", strings.Repeat("A", 64), strings.Repeat("0", 64), strings.Repeat("a", 63), strings.Repeat("g", 64)} {
if _, err := review.DispositionIntent(value, strings.Repeat("b", 64)); !errors.Is(err, ErrRecoveryDispositionInvalid) {
t.Fatal("invalid identifier accepted")
}
if _, err := review.DispositionIntent(strings.Repeat("a", 64), value); !errors.Is(err, ErrRecoveryDispositionInvalid) {
t.Fatal("invalid request key accepted")
}
}
record.Message = "changed"
if intent.MatchesRecord(record) {
t.Fatal("changed transaction accepted")
}
service.now = func() time.Time { return review.Summary().ExpiresAt }
if _, err := review.DispositionIntent(strings.Repeat("a", 64), strings.Repeat("b", 64)); !errors.Is(err, ErrRecoveryReviewExpired) {
t.Fatal("expired preparation accepted")
}
if _, err := (RecoveryReview{}).DispositionIntent(strings.Repeat("a", 64), strings.Repeat("b", 64)); err == nil {
t.Fatal("zero review accepted")
}
}

View File

@ -0,0 +1,105 @@
package repository
import (
"context"
"database/sql"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
)
// FinishRecoveryDisposition 仅由完成现场核验的内部执行器调用,不接受公网客户端结果。
// 不确定结果保留占用;成功结果、必要的原事务终态与结束审计在同一事务内提交。
func (r *SQLDeploymentTransactionRepository) FinishRecoveryDisposition(ctx context.Context, id, resultCode string) (deployment.RecoveryDisposition, error) {
if r == nil || r.db == nil {
return deployment.RecoveryDisposition{}, dispositionError(ctx)
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return deployment.RecoveryDisposition{}, dispositionError(ctx)
}
defer tx.Rollback()
query := `SELECT ` + recoveryDispositionColumns + ` FROM recovery_dispositions WHERE id = ?`
if r.driver != "sqlite" {
query += " FOR UPDATE"
}
item, err := scanRecoveryDisposition(tx.QueryRowContext(ctx, r.query(query), id))
if err != nil {
return deployment.RecoveryDisposition{}, dispositionError(ctx)
}
if !item.Intent.AllowsResult(resultCode) {
return deployment.RecoveryDisposition{}, deployment.ErrRecoveryDispositionInvalid
}
if item.Status != "running" {
if item.ResultCode == resultCode {
return item, nil
}
return deployment.RecoveryDisposition{}, deployment.ErrRecoveryDispositionConflict
}
var claim, start string
if err := tx.QueryRowContext(ctx, r.query(`SELECT disposition_id FROM recovery_claims WHERE transaction_id = ?`), item.Intent.TransactionID).Scan(&claim); err != nil || claim != id {
return deployment.RecoveryDisposition{}, deployment.ErrRecoveryDispositionConflict
}
if err := tx.QueryRowContext(ctx, r.query(`SELECT event_type FROM recovery_events WHERE disposition_id = ? AND event_type = 'started'`), id).Scan(&start); err != nil {
return deployment.RecoveryDisposition{}, dispositionError(ctx)
}
now := time.Now().UTC()
if err := r.finishRecoveryOriginal(ctx, tx, item.Intent, resultCode, now); err != nil {
return deployment.RecoveryDisposition{}, err
}
item.Status = "succeeded"
if resultCode == deployment.RecoveryResultRejected || resultCode == deployment.RecoveryResultUncertain {
item.Status = resultCode
}
result, err := tx.ExecContext(ctx, r.query(`UPDATE recovery_dispositions SET status = ?, result_code = ?, updated_at = ? WHERE id = ? AND status = 'running'`), item.Status, resultCode, now, id)
if err != nil || !oneRecoveryRow(result) {
return deployment.RecoveryDisposition{}, dispositionError(ctx)
}
if _, err := tx.ExecContext(ctx, r.query(`INSERT INTO recovery_events (disposition_id, event_type, result_code, created_at) VALUES (?, 'finished', ?, ?)`), id, resultCode, now); err != nil {
return deployment.RecoveryDisposition{}, dispositionError(ctx)
}
if resultCode != deployment.RecoveryResultUncertain {
result, err := tx.ExecContext(ctx, r.query(`DELETE FROM recovery_claims WHERE transaction_id = ? AND disposition_id = ?`), item.Intent.TransactionID, id)
if err != nil || !oneRecoveryRow(result) {
return deployment.RecoveryDisposition{}, dispositionError(ctx)
}
}
if err := tx.Commit(); err != nil {
return deployment.RecoveryDisposition{}, dispositionError(ctx)
}
item.ResultCode, item.UpdatedAt = resultCode, now
return item, nil
}
func oneRecoveryRow(result sql.Result) bool {
if result == nil {
return false
}
count, err := result.RowsAffected()
return err == nil && count == 1
}
func (r *SQLDeploymentTransactionRepository) finishRecoveryOriginal(ctx context.Context, tx *sql.Tx, intent deployment.RecoveryDispositionIntent, resultCode string, now time.Time) error {
if resultCode != deployment.RecoveryResultCommitted && resultCode != deployment.RecoveryResultRolledBack {
// 只记录核查、核验拒绝与不确定结果均不改变原事务;不能以已阅读代替恢复成功。
return nil
}
record, err := r.lockRecoveryTransaction(ctx, tx, intent.TransactionID)
if err != nil {
return dispositionError(ctx)
}
phase, status, message := deployment.TransactionPhaseCommitVerified, deployment.TransactionStatusCommitted, "人工处置已完成提交核验"
if resultCode == deployment.RecoveryResultRolledBack {
phase, status, message = deployment.TransactionPhaseRollbackVerified, deployment.TransactionStatusRolledBack, "人工处置已完成回滚核验"
}
if record.ServerID != intent.ServerID || record.Evidence == nil || record.Phase != phase ||
(record.Status != deployment.TransactionStatusPending && record.Status != deployment.TransactionStatusRecoveryRequired) {
return deployment.ErrRecoveryDispositionConflict
}
result, err := tx.ExecContext(ctx, r.query(`UPDATE deployment_transactions SET status = ?, phase = ?, message = ?, updated_at = ?
WHERE id = ? AND phase = ? AND status IN ('pending', 'recovery_required')`), status, status, message, now, intent.TransactionID, phase)
if err != nil || !oneRecoveryRow(result) {
return dispositionError(ctx)
}
return nil
}

View File

@ -0,0 +1,87 @@
package repository
import (
"errors"
"testing"
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
)
func TestRecoveryDispositionVerifiedCompletionAtomicity(t *testing.T) {
for _, action := range []deployment.RecoveryAction{deployment.RecoveryActionCommit, deployment.RecoveryActionRollback} {
for _, failure := range []string{"none", "original", "result", "audit", "release"} {
t.Run(string(action)+"/"+failure, func(t *testing.T) {
repo, db, intent := recoveryDispositionFixture(t, action)
if _, _, err := repo.BeginRecoveryDisposition(t.Context(), intent); err != nil {
t.Fatal(err)
}
phase, resultCode := deployment.TransactionPhaseCommitVerified, deployment.RecoveryResultCommitted
if action == deployment.RecoveryActionRollback {
phase, resultCode = deployment.TransactionPhaseRollbackVerified, deployment.RecoveryResultRolledBack
}
if _, err := repo.FinishRecoveryDisposition(t.Context(), intent.ID, resultCode); !errors.Is(err, deployment.ErrRecoveryDispositionConflict) {
t.Fatal("unverified original finalized", err)
}
if _, err := db.ExecContext(t.Context(), `UPDATE deployment_transactions SET phase = ?`, phase); err != nil {
t.Fatal(err)
}
triggers := map[string]string{
"original": "BEFORE UPDATE ON deployment_transactions",
"result": "BEFORE UPDATE ON recovery_dispositions",
"audit": "BEFORE INSERT ON recovery_events",
"release": "BEFORE DELETE ON recovery_claims",
}
if trigger, ok := triggers[failure]; ok {
if _, err := db.ExecContext(t.Context(), `CREATE TRIGGER reject_finish `+trigger+` BEGIN SELECT RAISE(ABORT, 'injected'); END`); err != nil {
t.Fatal(err)
}
}
item, err := repo.FinishRecoveryDisposition(t.Context(), intent.ID, resultCode)
var status, storedPhase string
if readErr := db.QueryRowContext(t.Context(), `SELECT status, phase FROM deployment_transactions WHERE id = ?`, intent.TransactionID).Scan(&status, &storedPhase); readErr != nil {
t.Fatal(readErr)
}
if failure == "none" {
if err != nil || item.Status != "succeeded" || status != resultCode || storedPhase != resultCode {
t.Fatal("atomic completion failed", err)
}
recoveryCount(t, db, "recovery_claims", 0)
recoveryCount(t, db, "recovery_events", 2)
if _, err := repo.FinishRecoveryDisposition(t.Context(), intent.ID, resultCode); err != nil {
t.Fatal(err)
}
} else {
if err == nil || status != deployment.TransactionStatusRecoveryRequired || storedPhase != phase {
t.Fatal("failed completion changed original")
}
stored, err := repo.GetRecoveryDisposition(t.Context(), intent.ID)
if err != nil || stored.Status != "running" || stored.ResultCode != "" {
t.Fatal("partial disposition outcome saved", err)
}
recoveryCount(t, db, "recovery_claims", 1)
recoveryCount(t, db, "recovery_events", 1)
}
})
}
}
}
func TestRecoveryDispositionRejectsCrossActionCompletion(t *testing.T) {
repo, db, intent := recoveryDispositionFixture(t, deployment.RecoveryActionRecord)
if _, _, err := repo.BeginRecoveryDisposition(t.Context(), intent); err != nil {
t.Fatal(err)
}
for _, result := range []string{deployment.RecoveryResultCommitted, deployment.RecoveryResultRolledBack, "unknown", ""} {
if _, err := repo.FinishRecoveryDisposition(t.Context(), intent.ID, result); !errors.Is(err, deployment.ErrRecoveryDispositionInvalid) {
t.Fatal("invalid result accepted", err)
}
}
if _, err := repo.FinishRecoveryDisposition(t.Context(), intent.ID, deployment.RecoveryResultRejected); err != nil {
t.Fatal(err)
}
recoveryCount(t, db, "recovery_claims", 0)
pending, err := repo.ListPendingDeploymentTransactions(t.Context())
if err != nil || len(pending) != 1 || pending[0].Evidence != nil {
t.Fatal("rejected review changed original", err)
}
}

View File

@ -0,0 +1,97 @@
package repository
import (
"errors"
"strings"
"testing"
"git.pcux.cn/xuans/Scert/backend/internal/database"
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
"git.pcux.cn/xuans/Scert/backend/internal/testsupport/mysqltest"
)
func TestRecoveryDispositionMySQLReal(t *testing.T) {
server := mysqltest.New(t)
for _, action := range []deployment.RecoveryAction{deployment.RecoveryActionRecord, deployment.RecoveryActionCommit, deployment.RecoveryActionRollback} {
for _, auditFailure := range []bool{false, true} {
name := string(action)
if auditFailure {
name += "/audit_failure"
}
t.Run(name, func(t *testing.T) {
cfg := server.Database(t)
db, err := database.Open(t.Context(), cfg)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
if err := database.Migrate(t.Context(), db.DB, cfg.Driver); err != nil {
t.Fatal(err)
}
repo, raw, intent := seedRecoveryDisposition(t, NewSQLDeploymentTransactionRepository(db.DB, cfg.Driver), db.DB, action)
if _, created, err := repo.BeginRecoveryDisposition(t.Context(), intent); err != nil || !created {
t.Fatal("start failed", err)
}
if _, created, err := repo.BeginRecoveryDisposition(t.Context(), intent); err != nil || created {
t.Fatal("retry authorized replay", err)
}
changed := intent
changed.Operator = "unix:1"
if _, _, err := repo.BeginRecoveryDisposition(t.Context(), changed); !errors.Is(err, deployment.ErrRecoveryDispositionConflict) {
t.Fatal("conflicting replay accepted", err)
}
changed = intent
changed.ID, changed.RequestKey = strings.Repeat("e", 64), strings.Repeat("f", 64)
if _, _, err := repo.BeginRecoveryDisposition(t.Context(), changed); !errors.Is(err, deployment.ErrRecoveryDispositionBusy) {
t.Fatal("duplicate claim accepted", err)
}
result, phase := deployment.RecoveryResultObserved, deployment.TransactionPhaseInstalled
if action == deployment.RecoveryActionCommit {
result, phase = deployment.RecoveryResultCommitted, deployment.TransactionPhaseCommitVerified
}
if action == deployment.RecoveryActionRollback {
result, phase = deployment.RecoveryResultRolledBack, deployment.TransactionPhaseRollbackVerified
}
if _, err := raw.ExecContext(t.Context(), `UPDATE deployment_transactions SET phase = ? WHERE id = ?`, phase, intent.TransactionID); err != nil {
t.Fatal(err)
}
if auditFailure {
if _, err := raw.ExecContext(t.Context(), `CREATE TRIGGER reject_finish BEFORE INSERT ON recovery_events FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'injected'`); err != nil {
t.Fatal(err)
}
}
item, err := repo.FinishRecoveryDisposition(t.Context(), intent.ID, result)
var status, storedPhase string
if readErr := raw.QueryRowContext(t.Context(), `SELECT status, phase FROM deployment_transactions WHERE id = ?`, intent.TransactionID).Scan(&status, &storedPhase); readErr != nil {
t.Fatal(readErr)
}
if auditFailure {
if err == nil || status != deployment.TransactionStatusRecoveryRequired || storedPhase != phase {
t.Fatal("audit failure committed partial outcome")
}
stored, err := repo.GetRecoveryDisposition(t.Context(), intent.ID)
if err != nil || stored.Status != "running" {
t.Fatal("failed completion changed disposition", err)
}
recoveryCount(t, raw, "recovery_events", 1)
recoveryCount(t, raw, "recovery_claims", 1)
} else {
if err != nil || item.Status != "succeeded" {
t.Fatal("completion failed", err)
}
if action != deployment.RecoveryActionRecord && (status != result || storedPhase != result) {
t.Fatal("original terminal state missing")
}
if action == deployment.RecoveryActionRecord && (status != deployment.TransactionStatusRecoveryRequired || storedPhase != phase) {
t.Fatal("observation changed original")
}
if _, err := repo.FinishRecoveryDisposition(t.Context(), intent.ID, result); err != nil {
t.Fatal("completion retry failed", err)
}
recoveryCount(t, raw, "recovery_events", 2)
recoveryCount(t, raw, "recovery_claims", 0)
}
})
}
}
}

View File

@ -0,0 +1,133 @@
package repository
import (
"context"
"database/sql"
"errors"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
)
var _ deployment.RecoveryDispositionJournal = (*SQLDeploymentTransactionRepository)(nil)
const recoveryDispositionColumns = `id, transaction_id, server_id, operator_id, action, request_key,
record_digest, server_digest, observation_digest, status, result_code, created_at, updated_at`
func scanRecoveryDisposition(row deploymentTransactionScanner) (deployment.RecoveryDisposition, error) {
var item deployment.RecoveryDisposition
i := &item.Intent
err := row.Scan(&i.ID, &i.TransactionID, &i.ServerID, &i.Operator, &i.Action, &i.RequestKey,
&i.RecordDigest, &i.ServerDigest, &i.ObservationDigest, &item.Status, &item.ResultCode, &item.CreatedAt, &item.UpdatedAt)
return item, err
}
func dispositionError(ctx context.Context) error {
if err := ctx.Err(); err != nil {
return err
}
return deployment.ErrRecoveryDispositionUnavailable
}
func (r *SQLDeploymentTransactionRepository) GetRecoveryDisposition(ctx context.Context, id string) (deployment.RecoveryDisposition, error) {
if r == nil || r.db == nil {
return deployment.RecoveryDisposition{}, dispositionError(ctx)
}
item, err := scanRecoveryDisposition(r.db.QueryRowContext(ctx, r.query(`SELECT `+recoveryDispositionColumns+` FROM recovery_dispositions WHERE id = ?`), id))
if err != nil && !errors.Is(err, sql.ErrNoRows) {
err = dispositionError(ctx)
}
return item, err
}
func (r *SQLDeploymentTransactionRepository) HasRecoveryClaim(ctx context.Context, transactionID string) (bool, error) {
if r == nil || r.db == nil {
return false, dispositionError(ctx)
}
var id string
err := r.db.QueryRowContext(ctx, r.query(`SELECT disposition_id FROM recovery_claims WHERE transaction_id = ?`), transactionID).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, dispositionError(ctx)
}
return true, nil
}
func (r *SQLDeploymentTransactionRepository) BeginRecoveryDisposition(ctx context.Context, intent deployment.RecoveryDispositionIntent) (deployment.RecoveryDisposition, bool, error) {
if err := intent.Validate(); err != nil {
return deployment.RecoveryDisposition{}, false, err
}
if r == nil || r.db == nil {
return deployment.RecoveryDisposition{}, false, dispositionError(ctx)
}
// 重试先读取既有结果;唯一约束负责并发首次请求,不能靠进程内缓存判定幂等。
if existing, err := r.findRecoveryRequest(ctx, intent.RequestKey); err == nil {
return recoveryRetry(existing, intent)
} else if !errors.Is(err, sql.ErrNoRows) {
return deployment.RecoveryDisposition{}, false, dispositionError(ctx)
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return deployment.RecoveryDisposition{}, false, dispositionError(ctx)
}
defer tx.Rollback()
now := time.Now().UTC()
item := deployment.RecoveryDisposition{Intent: intent, Status: "running", CreatedAt: now, UpdatedAt: now}
_, err = tx.ExecContext(ctx, r.query(`INSERT INTO recovery_dispositions (`+recoveryDispositionColumns+`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
intent.ID, intent.TransactionID, intent.ServerID, intent.Operator, intent.Action, intent.RequestKey,
intent.RecordDigest, intent.ServerDigest, intent.ObservationDigest, item.Status, "", now, now)
if err != nil {
_ = tx.Rollback()
if existing, readErr := r.findRecoveryRequest(ctx, intent.RequestKey); readErr == nil {
return recoveryRetry(existing, intent)
}
return deployment.RecoveryDisposition{}, false, dispositionError(ctx)
}
if _, err := tx.ExecContext(ctx, r.query(`INSERT INTO recovery_claims (transaction_id, disposition_id, created_at) VALUES (?, ?, ?)`), intent.TransactionID, intent.ID, now); err != nil {
_ = tx.Rollback()
if claimed, readErr := r.HasRecoveryClaim(ctx, intent.TransactionID); readErr == nil && claimed {
return deployment.RecoveryDisposition{}, false, deployment.ErrRecoveryDispositionBusy
}
return deployment.RecoveryDisposition{}, false, dispositionError(ctx)
}
record, err := r.lockRecoveryTransaction(ctx, tx, intent.TransactionID)
if err != nil {
return deployment.RecoveryDisposition{}, false, dispositionError(ctx)
}
if !intent.MatchesRecord(record) {
return deployment.RecoveryDisposition{}, false, deployment.ErrRecoveryReviewChanged
}
if _, err := tx.ExecContext(ctx, r.query(`INSERT INTO recovery_events (disposition_id, event_type, result_code, created_at) VALUES (?, 'started', '', ?)`), intent.ID, now); err != nil {
return deployment.RecoveryDisposition{}, false, dispositionError(ctx)
}
if err := tx.Commit(); err != nil {
// 提交结果不确定时不得授权动作;下次凭同一请求键读取持久记录。
return deployment.RecoveryDisposition{}, false, dispositionError(ctx)
}
return item, true, nil
}
func recoveryRetry(existing deployment.RecoveryDisposition, intent deployment.RecoveryDispositionIntent) (deployment.RecoveryDisposition, bool, error) {
if !existing.Intent.SameRequest(intent) {
return deployment.RecoveryDisposition{}, false, deployment.ErrRecoveryDispositionConflict
}
return existing, false, nil
}
func (r *SQLDeploymentTransactionRepository) findRecoveryRequest(ctx context.Context, key string) (deployment.RecoveryDisposition, error) {
return scanRecoveryDisposition(r.db.QueryRowContext(ctx, r.query(`SELECT `+recoveryDispositionColumns+` FROM recovery_dispositions WHERE request_key = ?`), key))
}
func (r *SQLDeploymentTransactionRepository) lockRecoveryTransaction(ctx context.Context, tx *sql.Tx, id string) (deployment.TransactionRecord, error) {
query := `SELECT id, task_id, attempt_id, attempt_number, result_index,
server_id, server_name, certificate_path, certificate_chain_path, private_key_path,
temporary_paths_json, installed_paths_json, backups_json, lock_paths_json,
reload_attempted, phase, status, message, updated_at, recovery_evidence_json
FROM deployment_transactions WHERE id = ?`
if r.driver != "sqlite" {
query += " FOR UPDATE"
}
return scanDeploymentTransaction(tx.QueryRowContext(ctx, r.query(query), id))
}

View File

@ -0,0 +1,257 @@
package repository
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"sync/atomic"
"testing"
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
)
func recoveryDispositionFixture(t *testing.T, action deployment.RecoveryAction) (*SQLDeploymentTransactionRepository, *sql.DB, deployment.RecoveryDispositionIntent) {
t.Helper()
_, db := newTaskRepositoryTest(t)
t.Cleanup(func() { _ = db.Close() })
repo := NewSQLDeploymentTransactionRepository(db)
return seedRecoveryDisposition(t, repo, db, action)
}
func seedRecoveryDisposition(t *testing.T, repo *SQLDeploymentTransactionRepository, db *sql.DB, action deployment.RecoveryAction) (*SQLDeploymentTransactionRepository, *sql.DB, deployment.RecoveryDispositionIntent) {
t.Helper()
record := deployment.TransactionRecord{
ID: "recovery-transaction", TaskID: "recovery-task", AttemptID: "recovery-attempt", AttemptNumber: 1,
ServerID: "recovery-server", ServerName: "server", Paths: []string{"/cert.pem", "/chain.pem", "/key.pem"},
Phase: deployment.TransactionPhaseInstalled, Status: deployment.TransactionStatusRecoveryRequired,
}
if action != deployment.RecoveryActionRecord {
record.Evidence = &deployment.RecoveryEvidence{Version: 1, LockToken: "fixture-secret-not-for-disposition"}
}
if err := repo.CreateDeploymentTransaction(t.Context(), record); err != nil {
t.Fatal(err)
}
stored, err := repo.ListPendingDeploymentTransactions(t.Context())
if err != nil || len(stored) != 1 {
t.Fatal("missing fixture transaction", err)
}
encoded, err := json.Marshal(stored[0])
if err != nil {
t.Fatal(err)
}
digest := sha256.Sum256(encoded)
intent := deployment.RecoveryDispositionIntent{
ID: strings.Repeat("a", 64), TransactionID: record.ID, ServerID: record.ServerID, Operator: "unix:0", Action: action,
RequestKey: strings.Repeat("b", 64), RecordDigest: hex.EncodeToString(digest[:]),
ServerDigest: strings.Repeat("c", 64), ObservationDigest: strings.Repeat("d", 64),
}
return repo, db, intent
}
func recoveryCount(t *testing.T, db *sql.DB, table string, want int) {
t.Helper()
var count int
if err := db.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM "+table).Scan(&count); err != nil || count != want {
t.Fatalf("%s rows: got %d want %d: %v", table, count, want, err)
}
}
func TestRecoveryDispositionRecordLifecycle(t *testing.T) {
repo, db, intent := recoveryDispositionFixture(t, deployment.RecoveryActionRecord)
before, _ := repo.ListPendingDeploymentTransactions(t.Context())
item, created, err := repo.BeginRecoveryDisposition(t.Context(), intent)
if err != nil || !created || item.Status != "running" {
t.Fatal("begin failed", err)
}
for _, table := range []string{"recovery_dispositions", "recovery_claims", "recovery_events"} {
recoveryCount(t, db, table, 1)
}
reopened := NewSQLDeploymentTransactionRepository(db)
if claimed, err := reopened.HasRecoveryClaim(t.Context(), intent.TransactionID); err != nil || !claimed {
t.Fatal("claim lost across repository restart", err)
}
if item, created, err := reopened.BeginRecoveryDisposition(t.Context(), intent); err != nil || created || item.Intent.ID != intent.ID {
t.Fatal("retry may not authorize a second execution", err)
}
for range 2 {
item, err = reopened.FinishRecoveryDisposition(t.Context(), intent.ID, deployment.RecoveryResultObserved)
if err != nil || item.Status != "succeeded" || item.ResultCode != deployment.RecoveryResultObserved {
t.Fatal("completion/retry failed", err)
}
}
recoveryCount(t, db, "recovery_claims", 0)
recoveryCount(t, db, "recovery_events", 2)
after, _ := repo.ListPendingDeploymentTransactions(t.Context())
beforeJSON, _ := json.Marshal(before)
afterJSON, _ := json.Marshal(after)
if string(beforeJSON) != string(afterJSON) {
t.Fatal("recording observation modified original transaction or manufactured evidence")
}
if _, created, err := repo.BeginRecoveryDisposition(t.Context(), intent); err != nil || created {
t.Fatal("completed request replayed", err)
}
if _, err := repo.FinishRecoveryDisposition(t.Context(), intent.ID, deployment.RecoveryResultUncertain); !errors.Is(err, deployment.ErrRecoveryDispositionConflict) {
t.Fatal("changed terminal outcome accepted", err)
}
}
func TestRecoveryDispositionRequestBinding(t *testing.T) {
for _, change := range []string{"transaction", "server", "operator", "action", "record", "configuration", "observation"} {
t.Run(change, func(t *testing.T) {
repo, db, intent := recoveryDispositionFixture(t, deployment.RecoveryActionRecord)
if _, _, err := repo.BeginRecoveryDisposition(t.Context(), intent); err != nil {
t.Fatal(err)
}
changed := intent
switch change {
case "transaction":
changed.TransactionID = "different"
case "server":
changed.ServerID = "different"
case "operator":
changed.Operator = "unix:1"
case "action":
changed.Action = deployment.RecoveryActionCommit
case "record":
changed.RecordDigest = strings.Repeat("e", 64)
case "configuration":
changed.ServerDigest = strings.Repeat("e", 64)
case "observation":
changed.ObservationDigest = strings.Repeat("e", 64)
}
if _, created, err := repo.BeginRecoveryDisposition(t.Context(), changed); !errors.Is(err, deployment.ErrRecoveryDispositionConflict) || created {
t.Fatal("conflicting idempotency key accepted", err)
}
changed = intent
changed.ID = strings.Repeat("f", 64)
if item, created, err := repo.BeginRecoveryDisposition(t.Context(), changed); err != nil || created || item.Intent.ID != intent.ID {
t.Fatal("same request must return original ID", err)
}
recoveryCount(t, db, "recovery_dispositions", 1)
})
}
}
func TestRecoveryDispositionBeginFailuresAreAtomic(t *testing.T) {
for _, failure := range []string{"recovery_dispositions", "recovery_claims", "recovery_events", "record_changed", "cancelled"} {
t.Run(failure, func(t *testing.T) {
repo, db, intent := recoveryDispositionFixture(t, deployment.RecoveryActionRecord)
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
switch failure {
case "record_changed":
if _, err := db.ExecContext(t.Context(), `UPDATE deployment_transactions SET message = 'changed'`); err != nil {
t.Fatal(err)
}
case "cancelled":
cancel()
default:
if _, err := db.ExecContext(t.Context(), `CREATE TRIGGER reject_begin BEFORE INSERT ON `+failure+` BEGIN SELECT RAISE(ABORT, 'injected'); END`); err != nil {
t.Fatal(err)
}
}
if _, created, err := repo.BeginRecoveryDisposition(ctx, intent); err == nil || created {
t.Fatal("failed start authorized execution")
}
for _, table := range []string{"recovery_dispositions", "recovery_claims", "recovery_events"} {
recoveryCount(t, db, table, 0)
}
})
}
}
func TestRecoveryDispositionUncertainKeepsClaim(t *testing.T) {
repo, db, intent := recoveryDispositionFixture(t, deployment.RecoveryActionCommit)
if _, _, err := repo.BeginRecoveryDisposition(t.Context(), intent); err != nil {
t.Fatal(err)
}
if _, err := repo.FinishRecoveryDisposition(t.Context(), intent.ID, deployment.RecoveryResultUncertain); err != nil {
t.Fatal(err)
}
if _, err := db.ExecContext(t.Context(), `UPDATE recovery_claims SET created_at = '2000-01-01 00:00:00'`); err != nil {
t.Fatal(err)
}
reopened := NewSQLDeploymentTransactionRepository(db)
intent.ID, intent.RequestKey = strings.Repeat("e", 64), strings.Repeat("f", 64)
if _, created, err := reopened.BeginRecoveryDisposition(t.Context(), intent); !errors.Is(err, deployment.ErrRecoveryDispositionBusy) || created {
t.Fatal("old uncertain claim must not auto-expire", err)
}
recoveryCount(t, db, "recovery_dispositions", 1)
recoveryCount(t, db, "recovery_claims", 1)
recoveryCount(t, db, "recovery_events", 2)
}
func TestRecoveryDispositionConcurrentRequests(t *testing.T) {
for _, sameKey := range []bool{true, false} {
t.Run(fmt.Sprint(sameKey), func(t *testing.T) {
repo, db, intent := recoveryDispositionFixture(t, deployment.RecoveryActionRecord)
db.SetMaxOpenConns(1)
var started atomic.Int32
var wg sync.WaitGroup
for i := range 8 {
wg.Add(1)
go func() {
defer wg.Done()
candidate := intent
if !sameKey {
candidate.ID, candidate.RequestKey = fmt.Sprintf("%064x", i+1), fmt.Sprintf("%064x", i+10)
}
_, created, err := repo.BeginRecoveryDisposition(t.Context(), candidate)
if created {
started.Add(1)
}
if sameKey && err != nil {
t.Errorf("same request failed: %v", err)
}
if !sameKey && err != nil && !errors.Is(err, deployment.ErrRecoveryDispositionBusy) {
t.Errorf("unexpected competing request error: %v", err)
}
}()
}
wg.Wait()
if started.Load() != 1 {
t.Fatal("multiple remote executions authorized")
}
for _, table := range []string{"recovery_dispositions", "recovery_claims", "recovery_events"} {
recoveryCount(t, db, table, 1)
}
})
}
}
func TestRecoveryDispositionClaimSurvivesDatabaseReopen(t *testing.T) {
repo, db, intent := recoveryDispositionFixture(t, deployment.RecoveryActionCommit)
if _, _, err := repo.BeginRecoveryDisposition(t.Context(), intent); err != nil {
t.Fatal(err)
}
var sequence int
var name, path string
if err := db.QueryRowContext(t.Context(), "PRAGMA database_list").Scan(&sequence, &name, &path); err != nil {
t.Fatal(err)
}
if err := db.Close(); err != nil {
t.Fatal(err)
}
reopened, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
defer reopened.Close()
repo = NewSQLDeploymentTransactionRepository(reopened)
if claimed, err := repo.HasRecoveryClaim(t.Context(), intent.TransactionID); err != nil || !claimed {
t.Fatal("incomplete operation lost its persistent claim", err)
}
if existing, created, err := repo.BeginRecoveryDisposition(t.Context(), intent); err != nil || created || existing.Status != "running" {
t.Fatal("reopened request must not authorize replay", err)
}
intent.ID, intent.RequestKey = strings.Repeat("e", 64), strings.Repeat("f", 64)
if _, _, err := repo.BeginRecoveryDisposition(t.Context(), intent); !errors.Is(err, deployment.ErrRecoveryDispositionBusy) {
t.Fatal("restart released unfinished claim", err)
}
}

View File

@ -1,5 +1,19 @@
# Linux 生产流程测试记录
## 2026年10月3日19:43 自动回归及实机边界
本节覆盖先前仍标运行中的结果;这些是第一台隔离目录中的原生 Linux Go 回归,不是正式候选包验收。未清理生产数据库、未手工改历史事务、未创建辅助验收脚本。
- [x] `final-full-http-race.log`:R2 最新后端全量退出码零;当时 HTTP 包四分钟累计超时,不能将当次整组记为通过。
- [x] `review-race-vet.log`:加入 R3 准备绑定后,部署、本机协议、管理员终端三包竞态、完整 `go vet -p 1 ./...`、HTTP 全包竞态分别退出码零;HTTP 约 374.825 秒;整组 `REVIEW_RUN_EXIT=0`。
- [x] `disposition-race-vet.log`:仓储约 104.544 秒、数据库约 133.013 秒、部署约 3.103 秒,三包竞态通过;完整静态检查通过;整组退出码零。测试单元限制内存 900 MiB、CPU 75%,以 nobody 运行。
- [~] 后续新增数据库关闭重开、准备摘要转换及 MySQL 实库专项,尚未取得结果;不能由上一批结果覆盖这些新增测试。
- [x] 第二台实机复核:根分区 29 GB,使用 2.9 GB,可用 26 GB,使用率 10%;五项既有服务 active,Nginx 配置检查通过。本轮没有扩大删除范围。
- [ ] 正式候选包、三数据库首次 Web、系统邮件、真实 DNS/ACME、公共 HTTPS、多对多推送和失败恢复仍需单独验收。
自动回归证据目录为第一台 `/var/tmp/scert-local-recovery-20261003-1828/`;第二台继续只用于证书推送测试。
## 2026年10月3日19:02 本机核查边界回归
第一台 `8.137.23.95` 隔离目录 `/var/tmp/scert-local-recovery-20261003-1828/`;不使用第二台运行本轮回归,不清理业务数据库或恢复现场。

View File

@ -1,5 +1,15 @@
# Scert 开发任务清单
## 2026年10月3日19:43 当前开发进度
- [x] R3 只读准备与重新核验基础已编码并通过 Linux 三包竞态;HTTP 全包竞态与完整静态检查通过。原后端最新全量也已通过。
- [x] R4 独立处置记录、唯一占用、幂等请求和原子开始/结束审计已编码;不确定结果保留占用,记录核查不改原事务。
- [x] 本轮持久化第一轮仓储、数据库、部署三包竞态和完整静态检查通过。
- [~] 数据库关闭重开、准备摘要转换与 MySQL 隔离实库专项正在补测;三数据库迁移文件已加入,不等于三数据库实库通过。
- [ ] 一次性本机确认、完整执行互斥、动作执行和本机终端接入仍未完成;R3/R4 整体不能标完成。
- [ ] 正式 Linux 候选包部署到第一台,第二台仅测试推送;生产门禁继续保留。
## 2026年10月3日19:02 当前开发进度
本节优先于历史章节。第一台用于部署,第二台仅用于推送;仅处理 Linux。

View File

@ -1,5 +1,15 @@
# Scert 整改任务执行清单
## 2026年10月3日19:43 当前执行状态
- [x] 修改前检查点 `ed0c97b` 已提交并推送,根目录 `=` 未暂存、未删除。
- [x] 最新准备绑定的三包竞态、完整静态检查及 HTTP 全包竞态通过;此前四分钟包累计超时已由十分钟有界回归获得真实通过结果,未跳过用例。
- [x] 持久化基础及第一轮仓储、数据库、部署三包竞态、完整静态检查通过;原子审计故障、幂等冲突、并发唯一占用及不确定保留占用有回归覆盖。
- [~] 补测数据库重开与 MySQL DDL 中断和实库处置审计;未把默认跳过的外部实库测试当作通过。
- [ ] 接入一次性确认、完整互斥与真实动作执行,再推进正式候选包和生产门禁。
- [~] 第二台已复核可用约 26 GB,未扩大清理;第一台仅运行受限隔离 Go 回归,未修改线上业务数据库或恢复终态。
## 2026年10月3日19:02 当前执行状态
本节优先于历史章节。第一台部署、第二台推送,Linux 范围不变。

View File

@ -1,5 +1,19 @@
# Scert 整改、合并与优化清单
## 2026年10月3日19:43 最新执行进度
本节覆盖下方历史状态。范围仍为 Linux;第一台部署测试,第二台仅证书推送,Smail 地址为 `web.cdis.cn`。状态标记:[ ] 未开始;[~] 进行中;[x] 已完成等待复审;[✓] 复审通过;[!] 阻塞。
- [x] R2 后端全量通过;R3 准备绑定加入后的部署、本机协议、管理员终端三包竞态、完整静态检查和 HTTP 全包竞态通过。HTTP 约 375 秒,整组退出码零;此前四分钟累计超时不再是当前阻塞。
- [x] R3 准备与重新核验基础通过原生 Linux 竞态:操作者、动作、事务、配置和现场摘要绑定;一分钟有效期;缺历史证据拒绝写动作准备。它不是执行授权,不能把 R3 整体标完成。
- [x] R4 持久化基础已编码:独立处置记录、事务唯一占用、开始与结束审计、幂等冲突拒绝;开始和结束分别原子落库。不确定结果保留占用,不按时间自动释放。
- [x] 第一轮仓储、数据库、部署三包竞态与后端完整静态检查通过;这里只包含隔离自动回归,不代表 MySQL/PostgreSQL 实库或正式安装流程通过。
- [~] 补充数据库关闭重开、准备摘要转换和 MySQL 实库 DDL 中断、审计失败专项;结果待取得。迁移为 22—24,每版仅一个表,复用 MySQL 严格结构核验,不改旧迁移回填行为。
- [ ] R3/R4 仍需一次性本机确认、执行器接入、普通部署与恢复的完整互斥、动作专属核验及最终原子结果联动;存储方法不对公网开放。
- [ ] R5 正式候选包、三数据库首次 Web、系统邮件与密码重置、DNS/ACME、严格公共 HTTPS、多对多部署及失败/重启恢复仍未完成。
- [~] 修改前检查点 `ed0c97b` 已推送;总体生产验收未通过,不能承诺已具备上线条件。
## 2026年10月3日19:02 最新进度
本节为最新状态,后续章节仅保留历史过程。第一台用于部署测试,第二台仅用于推送测试,Smail 为 `web.cdis.cn`,只处理 Linux。

View File

@ -1,5 +1,18 @@
# 部署事务人工处置设计
## 2026年10月3日19:43 持久化基础实现状态
- [x] 准备与重新核验基础已通过原生 Linux 竞态;HTTP 全包竞态与完整静态检查通过,准备对象仍不代表执行授权。
- [x] 新增迁移 22—24:独立处置记录、事务唯一占用、开始/结束事件。每版仅一个表,兼容现有 MySQL DDL 中断后结构校验机制,不影响迁移 15 的专用回填。
- [x] 开始操作在同一事务内保存请求绑定、占用和开始事件,并核对原事务摘要。相同请求返回原记录且不重新授权;同一幂等键不同绑定拒绝。
- [x] 结束操作在同一事务内保存处置结果、必要的原事务终态和结束事件;任一步失败整体回滚。只读核查不改原事务;不确定结果保留占用;仅允许已核验阶段写入对应提交/回滚终态。
- [x] 第一轮仓储、数据库、部署三包竞态及完整静态检查通过;故障注入覆盖开始写入、结束结果、原事务、审计及占用释放。
- [~] 数据库关闭重开、准备摘要转换、MySQL 实库迁移和审计测试补测中。
- [ ] 尚未接入一次性确认、本机协议和完整生命周期互斥;尚未实现动作专属执行器。这些仓储接口仅是内部持久化能力,不是用户可用的提交或回滚入口。
本轮没有替代 R3/R4 整体目标:历史阶段只是存储层防误用条件,不能单独证明本次人工处置做过远端检查。执行器必须在持久化占用和完整互斥内重新执行身份、文件、锁及严格 HTTPS 核验;不能直接利用历史已核验阶段跳过现场验证。
## 2026年10月3日 设计与任务拆分
本文件对应《部署事务人工核查任务清单》R3、R4,是待实现的约束和执行清单,不是已完成能力或验收结论。范围仅限 Linux:第一台部署 Scert,第二台作为证书推送目标。

View File

@ -1,5 +1,15 @@
# 部署事务人工核查任务清单
## 2026年10月3日19:43 最新任务状态
- [x] R1/R2 底层及本机入口的最新 Linux 三包竞态、完整静态检查、HTTP 全包竞态通过;仍待正式包复审。
- [~] R3-B/R3-C:准备绑定及重新核验基础通过;一次性确认凭据、本机终端和动作执行尚未接入。
- [~] R3-D/R4-A/R4-B:独立处置表、占用表、事件表和仓储已编码;原子开始/结束、幂等冲突、不确定占用保留的第一轮竞态通过。完整部署生命周期互斥尚未接入。
- [~] R4-C/R4-D:故障注入第一轮通过;补测真实数据库重开、MySQL DDL 中断与实库审计。PostgreSQL 实库尚无本轮证据。
- [ ] R3-E/R5:真实动作资格、执行链路、正式候选包和双机生产闭环未完成。
- [~] R6:同步总清单、开发清单、执行清单、设计和 Linux 测试记录;生产验收保持未通过。
## 2026年10月3日19:02 边界整改结果
本节优先于历史记录。