206 lines
6.5 KiB
Go
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
|
|
}
|