oto/services/core/internal/cicdstate/store.go
toki 5fd116e447 feat(control-plane): CI/CD 상태 API를 추가한다
OTO Core가 job, execution, log, artifact 상태를 서버 내부에서 소유하고 조회할 수 있어야 runner dispatch와 통합 smoke 단계로 이어질 수 있다. in-memory 상태 저장소와 HTTP API, 검증 테스트, 관련 agent-task 산출물을 함께 반영한다.
2026-06-06 06:22:47 +09:00

323 lines
6.2 KiB
Go

package cicdstate
import (
"fmt"
"sync"
"time"
)
const (
StateQueued = "queued"
StateRunning = "running"
StateSucceeded = "succeeded"
StateFailed = "failed"
StateCanceled = "canceled"
)
var validTransitions = map[string][]string{
StateQueued: {StateRunning, StateFailed, StateCanceled},
StateRunning: {StateSucceeded, StateFailed},
StateSucceeded: nil,
StateFailed: nil,
StateCanceled: nil,
}
type Job struct {
ID string
Name string
State string
CreatedAt time.Time
UpdatedAt time.Time
ExecutionID string
}
func (j *Job) TransitionTo(newState string) error {
allowed, ok := validTransitions[j.State]
if !ok {
return fmt.Errorf("unknown state: %s", j.State)
}
for _, s := range allowed {
if s == newState {
j.State = newState
j.UpdatedAt = time.Now()
return nil
}
}
return fmt.Errorf("invalid transition from %s to %s", j.State, newState)
}
type Execution struct {
ID string
JobID string
State string
CreatedAt time.Time
UpdatedAt time.Time
Logs []LogEntry
Artifacts []ArtifactEntry
}
func (e *Execution) TransitionTo(newState string) error {
allowed, ok := validTransitions[e.State]
if !ok {
return fmt.Errorf("unknown state: %s", e.State)
}
for _, s := range allowed {
if s == newState {
e.State = newState
e.UpdatedAt = time.Now()
return nil
}
}
return fmt.Errorf("invalid transition from %s to %s", e.State, newState)
}
type LogEntry struct {
Timestamp time.Time
Line string
}
type ArtifactEntry struct {
Name string
Path string
}
type Store struct {
mu sync.RWMutex
jobs map[string]*Job
executions map[string]*Execution
now func() time.Time
}
func NewStore() *Store {
return &Store{
now: time.Now,
jobs: make(map[string]*Job),
executions: make(map[string]*Execution),
}
}
func (s *Store) CreateJob(id, name string) (*Job, error) {
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.jobs[id]; exists {
return nil, fmt.Errorf("job %s already exists", id)
}
now := s.now()
job := &Job{
ID: id,
Name: name,
State: StateQueued,
CreatedAt: now,
UpdatedAt: now,
}
s.jobs[id] = job
return job, nil
}
func (s *Store) GetJob(id string) (*Job, error) {
s.mu.RLock()
defer s.mu.RUnlock()
job, ok := s.jobs[id]
if !ok {
return nil, fmt.Errorf("job not found: %s", id)
}
return copyJob(job), nil
}
func (s *Store) CreateExecution(jobID, execID string) (*Execution, error) {
s.mu.Lock()
defer s.mu.Unlock()
job, ok := s.jobs[jobID]
if !ok {
return nil, fmt.Errorf("job not found: %s", jobID)
}
if job.ExecutionID != "" {
return nil, fmt.Errorf("job %s already has execution %s", jobID, job.ExecutionID)
}
now := s.now()
exec := &Execution{
ID: execID,
JobID: jobID,
State: StateQueued,
CreatedAt: now,
UpdatedAt: now,
Logs: make([]LogEntry, 0),
Artifacts: make([]ArtifactEntry, 0),
}
s.executions[execID] = exec
job.ExecutionID = execID
job.UpdatedAt = now
return exec, nil
}
func (s *Store) GetExecution(id string) (*Execution, error) {
s.mu.RLock()
defer s.mu.RUnlock()
exec, ok := s.executions[id]
if !ok {
return nil, fmt.Errorf("execution not found: %s", id)
}
return copyExecution(exec), nil
}
func (s *Store) AppendLog(execID string, line string) error {
s.mu.Lock()
defer s.mu.Unlock()
exec, ok := s.executions[execID]
if !ok {
return fmt.Errorf("execution not found: %s", execID)
}
exec.Logs = append(exec.Logs, LogEntry{
Timestamp: s.now(),
Line: line,
})
exec.UpdatedAt = s.now()
return nil
}
func (s *Store) GetLogs(execID string) ([]LogEntry, error) {
s.mu.RLock()
defer s.mu.RUnlock()
exec, ok := s.executions[execID]
if !ok {
return nil, fmt.Errorf("execution not found: %s", execID)
}
result := make([]LogEntry, len(exec.Logs))
copy(result, exec.Logs)
return result, nil
}
func (s *Store) AppendArtifact(execID string, name string, path string) error {
s.mu.Lock()
defer s.mu.Unlock()
exec, ok := s.executions[execID]
if !ok {
return fmt.Errorf("execution not found: %s", execID)
}
exec.Artifacts = append(exec.Artifacts, ArtifactEntry{
Name: name,
Path: path,
})
exec.UpdatedAt = s.now()
return nil
}
func (s *Store) GetArtifacts(execID string) ([]ArtifactEntry, error) {
s.mu.RLock()
defer s.mu.RUnlock()
exec, ok := s.executions[execID]
if !ok {
return nil, fmt.Errorf("execution not found: %s", execID)
}
result := make([]ArtifactEntry, len(exec.Artifacts))
copy(result, exec.Artifacts)
return result, nil
}
func (s *Store) TransitionJob(id, newState string) error {
s.mu.Lock()
defer s.mu.Unlock()
job, ok := s.jobs[id]
if !ok {
return fmt.Errorf("job not found: %s", id)
}
allowed, ok := validTransitions[job.State]
if !ok {
return fmt.Errorf("unknown state: %s", job.State)
}
for _, target := range allowed {
if target == newState {
job.State = newState
job.UpdatedAt = s.now()
return nil
}
}
return fmt.Errorf("invalid transition from %s to %s", job.State, newState)
}
func (s *Store) TransitionExecution(id, newState string) error {
s.mu.Lock()
defer s.mu.Unlock()
exec, ok := s.executions[id]
if !ok {
return fmt.Errorf("execution not found: %s", id)
}
allowed, ok := validTransitions[exec.State]
if !ok {
return fmt.Errorf("unknown state: %s", exec.State)
}
for _, target := range allowed {
if target == newState {
exec.State = newState
exec.UpdatedAt = s.now()
return nil
}
}
return fmt.Errorf("invalid transition from %s to %s", exec.State, newState)
}
func (s *Store) SnapshotJobs() []Job {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]Job, 0, len(s.jobs))
for _, j := range s.jobs {
result = append(result, *copyJob(j))
}
return result
}
func (s *Store) SnapshotExecutions() []Execution {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]Execution, 0, len(s.executions))
for _, e := range s.executions {
result = append(result, *copyExecution(e))
}
return result
}
func copyJob(j *Job) *Job {
if j == nil {
return nil
}
cp := *j
return &cp
}
func copyExecution(e *Execution) *Execution {
if e == nil {
return nil
}
cp := *e
cp.Logs = make([]LogEntry, len(e.Logs))
copy(cp.Logs, e.Logs)
cp.Artifacts = make([]ArtifactEntry, len(e.Artifacts))
copy(cp.Artifacts, e.Artifacts)
return &cp
}