Files
2026-05-12 18:49:52 +08:00

206 lines
6.5 KiB
Go

package admission
import (
"context"
"errors"
"strconv"
"time"
)
var (
ErrCandidateNotFound = errors.New("candidate not found")
ErrInvalidCandidateID = errors.New("invalid candidate id")
ErrTestTimeout = errors.New("admission test timed out")
ErrCandidateNotRunnable = errors.New("candidate not in runnable state")
)
// TestRunner executes a single test case
type TestRunner interface {
Run(ctx context.Context, tc TestCase) TestCaseResult
}
// TestCaseResult is the outcome of a single test case execution
type TestCaseResult struct {
Passed bool
StatusCode int
LatencyMs int
Error string
ResponseLen int
}
// Service orchestrates the admission testing workflow
type Service struct {
candidateRepo CandidateRepository
packageRepo SupplyPackageRepository
testSuites map[string]TestSuite // key = platform
testLogger TestLogger
runner TestRunner
now func() time.Time
}
// NewService creates a new admission service
func NewService(candidateRepo CandidateRepository, packageRepo SupplyPackageRepository, suites []TestSuite, runner TestRunner, testLogger TestLogger) *Service {
suiteMap := make(map[string]TestSuite)
for _, s := range suites {
suiteMap[s.Platform] = s
}
return &Service{
candidateRepo: candidateRepo,
packageRepo: packageRepo,
testSuites: suiteMap,
runner: runner,
testLogger: testLogger,
now: func() time.Time { return time.Now().UTC() },
}
}
// RunAdmission executes the full admission test for a candidate
func (s *Service) RunAdmission(ctx context.Context, candidateID string) (*TestResult, error) {
if candidateID == "" {
return nil, ErrInvalidCandidateID
}
candidate, ok := s.candidateRepo.GetCandidateByIDContext(ctx, candidateID)
if !ok {
return nil, ErrCandidateNotFound
}
// Candidate must be in discovered/retry_pending state to run
switch candidate.Status {
case CandidateStatusDiscovered, CandidateStatusRetryPending:
// runnable
default:
return nil, ErrCandidateNotRunnable
}
testedAt := s.now()
if err := s.candidateRepo.UpdateCandidateStatus(ctx, candidateID, CandidateStatusTesting, "", ""); err != nil {
return nil, err
}
suite, ok := s.testSuites[candidate.Platform]
if !ok {
failureCode := "test_suite_missing"
failureSummary := "no admission test suite configured for platform: " + candidate.Platform
if err := s.candidateRepo.UpdateCandidateStatus(ctx, candidateID, CandidateStatusTestFailed, failureCode, failureSummary); err != nil {
return nil, err
}
if s.testLogger != nil {
_ = s.testLogger.AppendAdmissionTestLog(ctx, candidateID, string(CandidateStatusTestFailed), failureCode, failureSummary, testedAt.Format(time.RFC3339))
}
return &TestResult{
CandidateID: candidateID,
Status: CandidateStatusTestFailed,
TestedAt: testedAt,
FailureCode: failureCode,
FailureSummary: failureSummary,
Passed: false,
}, nil
}
// Execute all test cases
var failedCases []string
var failureCode string
var failureSummary string
for _, tc := range suite.Cases {
timeoutCtx, cancel := context.WithTimeout(ctx, time.Duration(tc.TimeoutSecs)*time.Second)
result := s.runner.Run(timeoutCtx, tc)
cancel()
if !result.Passed {
failedCases = append(failedCases, tc.Name)
if failureCode == "" {
failureCode = classifyFailure(result, tc)
failureSummary = formatFailure(result, tc)
}
}
}
if len(failedCases) > 0 {
if err := s.candidateRepo.UpdateCandidateStatus(ctx, candidateID, CandidateStatusTestFailed, failureCode, failureSummary); err != nil {
return nil, err
}
if s.testLogger != nil {
_ = s.testLogger.AppendAdmissionTestLog(ctx, candidateID, string(CandidateStatusTestFailed), failureCode, failureSummary, testedAt.Format(time.RFC3339))
}
if s.testLogger != nil {
_ = s.testLogger.AppendAdmissionTestLog(ctx, candidateID, string(CandidateStatusTestFailed), failureCode, failureSummary, testedAt.Format(time.RFC3339))
}
return &TestResult{
CandidateID: candidateID,
Status: CandidateStatusTestFailed,
TestedAt: testedAt,
FailureCode: failureCode,
FailureSummary: failureSummary,
Passed: false,
}, nil
}
// All cases passed — generate draft package
_, err := s.packageRepo.UpsertDraftPackage(ctx, candidate.Platform, candidate.Model, candidate.Source)
if err != nil {
failureCode = "draft_generation_failed"
failureSummary = err.Error()
if updateErr := s.candidateRepo.UpdateCandidateStatus(ctx, candidateID, CandidateStatusTestFailed, failureCode, failureSummary); updateErr != nil {
return nil, updateErr
}
if s.testLogger != nil {
_ = s.testLogger.AppendAdmissionTestLog(ctx, candidateID, string(CandidateStatusTestFailed), failureCode, failureSummary, testedAt.Format(time.RFC3339))
}
return &TestResult{
CandidateID: candidateID,
Status: CandidateStatusTestFailed,
TestedAt: testedAt,
FailureCode: failureCode,
FailureSummary: failureSummary,
Passed: false,
}, nil
}
if err := s.candidateRepo.UpdateCandidateStatus(ctx, candidateID, CandidateStatusTestPassed, "", ""); err != nil {
return nil, err
}
if s.testLogger != nil {
_ = s.testLogger.AppendAdmissionTestLog(ctx, candidateID, string(CandidateStatusTestPassed), "", "", testedAt.Format(time.RFC3339))
}
return &TestResult{
CandidateID: candidateID,
Status: CandidateStatusTestPassed,
TestedAt: testedAt,
Passed: true,
}, nil
}
// classifyFailure determines the failure code from a failed test case result
func classifyFailure(result TestCaseResult, tc TestCase) string {
if result.Error != "" {
if result.Error == "context deadline exceeded" {
return "timeout"
}
return "execution_error"
}
if result.StatusCode >= 500 {
return "upstream_error"
}
if result.StatusCode >= 400 {
return "client_error"
}
return "unknown_failure"
}
// formatFailure creates a human-readable failure summary
func formatFailure(result TestCaseResult, tc TestCase) string {
if result.Error != "" {
return tc.Name + ": " + result.Error
}
return tc.Name + ": status=" + strconv.Itoa(result.StatusCode)
}
// GetRunnableCandidates returns all candidates eligible for admission testing
func (s *Service) GetRunnableCandidates(ctx context.Context) []Candidate {
candidates := s.candidateRepo.ListCandidatesByStatus(ctx, CandidateStatusDiscovered)
candidates = append(candidates, s.candidateRepo.ListCandidatesByStatus(ctx, CandidateStatusRetryPending)...)
return candidates
}