242 lines
11 KiB
Go
242 lines
11 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"errors"
|
|
"maps"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
|
|
)
|
|
|
|
// executorIntegrationClient 仅模拟远端文件;处置占用、审计和终态使用真实 SQLite。
|
|
// 未列出的远端方法故意保持不可用,避免测试意外放行额外写操作。
|
|
type executorIntegrationClient struct {
|
|
deployment.Client
|
|
files map[string]string
|
|
}
|
|
|
|
var _ deployment.EvidenceClient = (*executorIntegrationClient)(nil)
|
|
|
|
func (c *executorIntegrationClient) CreateOwnedLock(context.Context, string, string) error {
|
|
return errors.New("人工处置不能创建新的部署锁")
|
|
}
|
|
|
|
func (c *executorIntegrationClient) Connect(context.Context, deployment.Server) (deployment.Client, error) {
|
|
return c, nil
|
|
}
|
|
func (c *executorIntegrationClient) Close() error { return nil }
|
|
func (c *executorIntegrationClient) Exists(_ context.Context, path string) (bool, error) {
|
|
_, exists := c.files[path]
|
|
return exists, nil
|
|
}
|
|
func (c *executorIntegrationClient) FileDigest(_ context.Context, path string) (string, error) {
|
|
value, exists := c.files[path]
|
|
if !exists {
|
|
return "", errors.New("测试文件不存在")
|
|
}
|
|
return executorDigest(value), nil
|
|
}
|
|
func (c *executorIntegrationClient) ReadLockOwner(_ context.Context, path string) (string, error) {
|
|
return c.files[path+"/owner"], nil
|
|
}
|
|
func (c *executorIntegrationClient) Remove(_ context.Context, path string) error {
|
|
delete(c.files, path)
|
|
return nil
|
|
}
|
|
func (c *executorIntegrationClient) Rename(_ context.Context, from, to string) error {
|
|
value, exists := c.files[from]
|
|
if !exists {
|
|
return errors.New("测试源文件不存在")
|
|
}
|
|
c.files[to] = value
|
|
delete(c.files, from)
|
|
return nil
|
|
}
|
|
func (c *executorIntegrationClient) Unlock(_ context.Context, path string) error {
|
|
delete(c.files, path)
|
|
delete(c.files, path+"/owner")
|
|
return nil
|
|
}
|
|
func (c *executorIntegrationClient) Run(context.Context, []string) (deployment.CommandResult, error) {
|
|
return deployment.CommandResult{}, nil
|
|
}
|
|
|
|
func executorDigest(value string) string {
|
|
digest := sha256.Sum256([]byte(value))
|
|
return hex.EncodeToString(digest[:])
|
|
}
|
|
|
|
func executorIntegrationFixture(t *testing.T) (*SQLDeploymentTransactionRepository, *sql.DB, *deployment.Service, *executorIntegrationClient, deployment.Server, deployment.TransactionRecord) {
|
|
t.Helper()
|
|
_, db := newTaskRepositoryTest(t)
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
repo := NewSQLDeploymentTransactionRepository(db)
|
|
return seedRecoveryExecutor(t, repo, db)
|
|
}
|
|
|
|
func seedRecoveryExecutor(t *testing.T, repo *SQLDeploymentTransactionRepository, db *sql.DB) (*SQLDeploymentTransactionRepository, *sql.DB, *deployment.Service, *executorIntegrationClient, deployment.Server, deployment.TransactionRecord) {
|
|
t.Helper()
|
|
server := deployment.Server{
|
|
ID: "executor-server", Name: "executor-server", Address: "192.0.2.10", Port: 22, Username: "deploy",
|
|
AuthMethod: deployment.SSHAuthPassword, PasswordRef: "secret://ssh/password/test", HostKeyFingerprint: "SHA256:test",
|
|
CertificatePath: "/cert.pem", CertificateChainPath: "/chain.pem", PrivateKeyPath: "/key.pem", WebServer: deployment.WebServerNginx,
|
|
}.Normalize()
|
|
record := deployment.TransactionRecord{
|
|
ID: "executor-transaction", TaskID: "task", AttemptID: "attempt", AttemptNumber: 1, ServerID: server.ID, ServerName: server.Name,
|
|
Paths: []string{server.CertificatePath, server.CertificateChainPath, server.PrivateKeyPath},
|
|
Status: deployment.TransactionStatusRecoveryRequired, Phase: deployment.TransactionPhaseReloadAttempted, ReloadAttempted: true,
|
|
Evidence: &deployment.RecoveryEvidence{
|
|
Version: 1, LockToken: executorDigest("owner"), Address: server.Address, Port: server.Port, HostKeyFingerprint: server.HostKeyFingerprint,
|
|
ValidationCommand: server.ValidationCommand, ReloadCommand: server.ReloadCommand, Files: map[string]string{}, TemporaryFiles: map[string]string{},
|
|
Baseline: deployment.RecoveryBaseline{Endpoint: "https://example.com", Domains: []string{"example.com"}, PreviousFingerprint: "old", ExpectedFingerprint: "new"},
|
|
},
|
|
}
|
|
client := &executorIntegrationClient{files: map[string]string{}}
|
|
for _, path := range record.Paths {
|
|
lock, backup, temporary := path+".scert-lock", path+".scert-backup-"+record.ID, path+".scert-tmp-"+record.ID
|
|
record.LockPaths = append(record.LockPaths, lock)
|
|
record.TemporaryPaths = append(record.TemporaryPaths, temporary)
|
|
record.InstalledPaths = append(record.InstalledPaths, path)
|
|
record.Backups = append(record.Backups, deployment.TransactionBackup{Original: path, Saved: backup, OriginalExists: true, OriginalDigest: executorDigest("old")})
|
|
record.Evidence.Files[path], record.Evidence.TemporaryFiles[temporary] = executorDigest("new"), executorDigest("temporary")
|
|
client.files[path], client.files[backup], client.files[temporary] = "new", "old", "temporary"
|
|
client.files[lock], client.files[lock+"/owner"] = "", record.Evidence.LockToken
|
|
}
|
|
if err := repo.CreateDeploymentTransaction(t.Context(), record); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
service := deployment.NewServiceWithJournal(client, repo)
|
|
service.VerifyRecoveryTarget = func(context.Context, deployment.Server, deployment.RecoveryBaseline, bool) error { return nil }
|
|
return repo, db, service, client, server, record
|
|
}
|
|
|
|
func TestRecoveryExecutorSQLiteAtomicLifecycle(t *testing.T) {
|
|
for _, action := range []deployment.RecoveryAction{deployment.RecoveryActionRecord, deployment.RecoveryActionCommit, deployment.RecoveryActionRollback} {
|
|
t.Run(string(action), func(t *testing.T) {
|
|
repo, db, service, client, server, record := executorIntegrationFixture(t)
|
|
before, err := repo.ListPendingDeploymentTransactions(t.Context())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
filesBefore := maps.Clone(client.files)
|
|
resolve := func(context.Context, string) (deployment.Server, error) { return server, nil }
|
|
confirmation, token, err := service.PrepareRecoveryConfirmation(t.Context(), record.ID, "unix:0", action, resolve)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
item, err := service.ExecuteRecoveryDisposition(t.Context(), confirmation, "unix:0", token, resolve)
|
|
if err != nil || item.Status != "succeeded" {
|
|
t.Fatalf("execution failed: %+v %v", item, err)
|
|
}
|
|
recoveryCount(t, db, "recovery_dispositions", 1)
|
|
recoveryCount(t, db, "recovery_events", 2)
|
|
recoveryCount(t, db, "recovery_claims", 0)
|
|
stored, err := repo.GetRecoveryDisposition(t.Context(), confirmation.DispositionID())
|
|
if err != nil || !reflect.DeepEqual(stored, item) {
|
|
t.Fatal("result query lost completed result", err)
|
|
}
|
|
view, err := service.GetRecoveryDisposition(t.Context(), confirmation.DispositionID())
|
|
if err != nil || view.Validate() != nil || !reflect.DeepEqual(view, item.Summary()) {
|
|
t.Fatal("safe query omitted or changed persisted result", err)
|
|
}
|
|
var status string
|
|
if err := db.QueryRowContext(t.Context(), "SELECT status FROM deployment_transactions WHERE id = ?", record.ID).Scan(&status); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
switch action {
|
|
case deployment.RecoveryActionRecord:
|
|
after, err := repo.ListPendingDeploymentTransactions(t.Context())
|
|
if err != nil || !reflect.DeepEqual(before, after) || !reflect.DeepEqual(filesBefore, client.files) {
|
|
t.Fatal("record-only changed original state", err)
|
|
}
|
|
case deployment.RecoveryActionCommit:
|
|
if status != deployment.TransactionStatusCommitted || client.files[record.Paths[0]] != "new" {
|
|
t.Fatal("commit not atomic with audit")
|
|
}
|
|
case deployment.RecoveryActionRollback:
|
|
if status != deployment.TransactionStatusRolledBack || client.files[record.Paths[0]] != "old" {
|
|
t.Fatal("rollback not atomic with audit")
|
|
}
|
|
}
|
|
if _, err := service.ExecuteRecoveryDisposition(t.Context(), confirmation, "unix:0", token, resolve); !errors.Is(err, deployment.ErrRecoveryConfirmation) {
|
|
t.Fatal("completed confirmation replayed")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRecoveryExecutorSQLiteAuditFailuresAndRestart(t *testing.T) {
|
|
for _, stage := range []string{"started", "finished"} {
|
|
t.Run(stage, func(t *testing.T) {
|
|
repo, db, service, client, server, record := executorIntegrationFixture(t)
|
|
resolve := func(context.Context, string) (deployment.Server, error) { return server, nil }
|
|
confirmation, token, err := service.PrepareRecoveryConfirmation(t.Context(), record.ID, "unix:0", deployment.RecoveryActionCommit, resolve)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
query := "CREATE TRIGGER executor_audit_failure BEFORE INSERT ON recovery_events WHEN NEW.event_type = '" + stage + "' BEGIN SELECT RAISE(ABORT, '测试审计失败'); END"
|
|
if _, err := db.ExecContext(t.Context(), query); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
filesBefore := maps.Clone(client.files)
|
|
item, err := service.ExecuteRecoveryDisposition(t.Context(), confirmation, "unix:0", token, resolve)
|
|
if !errors.Is(err, deployment.ErrRecoveryDispositionUnavailable) || item.Status == "succeeded" {
|
|
t.Fatal("audit failure reported success")
|
|
}
|
|
if stage == "started" {
|
|
recoveryCount(t, db, "recovery_dispositions", 0)
|
|
recoveryCount(t, db, "recovery_claims", 0)
|
|
recoveryCount(t, db, "recovery_events", 0)
|
|
if !reflect.DeepEqual(filesBefore, client.files) {
|
|
t.Fatal("begin audit failure modified remote")
|
|
}
|
|
return
|
|
}
|
|
recoveryCount(t, db, "recovery_claims", 1)
|
|
recoveryCount(t, db, "recovery_events", 1)
|
|
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)
|
|
stored, err := repo.GetRecoveryDisposition(t.Context(), confirmation.DispositionID())
|
|
if err != nil || stored.Status != "running" {
|
|
t.Fatal("uncertain result lost after reopening", err)
|
|
}
|
|
pending, err := repo.ListPendingDeploymentTransactions(t.Context())
|
|
if err != nil || len(pending) != 1 || pending[0].Status != deployment.TransactionStatusRecoveryRequired || pending[0].Phase != deployment.TransactionPhaseCommitVerified {
|
|
t.Fatal("failed finish altered original terminal state", err)
|
|
}
|
|
if strings.Contains(stored.Intent.RecordDigest, token) {
|
|
t.Fatal("confirmation token persisted")
|
|
}
|
|
restarted := deployment.NewServiceWithJournal(nil, repo)
|
|
view, err := restarted.GetRecoveryDisposition(t.Context(), confirmation.DispositionID())
|
|
if err != nil || view.Status != "running" || view.ID != confirmation.DispositionID() {
|
|
t.Fatal("query after process restart failed", err)
|
|
}
|
|
if err := restarted.RecoverPending(t.Context(), func(context.Context, string) (deployment.Server, error) {
|
|
t.Error("restart re-executed occupied transaction")
|
|
return deployment.Server{}, errors.New("unexpected resolver")
|
|
}); !errors.Is(err, deployment.ErrRecoveryDispositionBusy) {
|
|
t.Fatal("restart did not honor durable claim", err)
|
|
}
|
|
})
|
|
}
|
|
}
|