로드맵 milestone 상태를 archive로 이동하고 작업 메타데이터 추적을 정합성 있게 반영한다. core의 상태 저장 경로 변경 반영과 핸들러 파일 분리로 DTO 처리·라우트 의존을 정리했다.
394 lines
8.1 KiB
Go
394 lines
8.1 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, StateCanceled},
|
|
StateSucceeded: nil,
|
|
StateFailed: nil,
|
|
StateCanceled: nil,
|
|
}
|
|
|
|
// RunInput holds the remote run request payload stored on a job so that a
|
|
// runner can receive it when it claims the job. It deliberately avoids any
|
|
// protobuf dependency; conversion to/from otopb.RunRequest happens at the HTTP
|
|
// boundary.
|
|
type RunInput struct {
|
|
PipelineYAMLPath string
|
|
PipelineYAML string
|
|
Variables map[string]string
|
|
CommandTypes []string
|
|
}
|
|
|
|
type Job struct {
|
|
ID string
|
|
Name string
|
|
State string
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
ExecutionID string
|
|
RunInput *RunInput
|
|
}
|
|
|
|
func (j *Job) transitionTo(newState string, updatedAt time.Time) 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 = updatedAt
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("invalid transition from %s to %s", j.State, newState)
|
|
}
|
|
|
|
type Execution struct {
|
|
ID string
|
|
JobID string
|
|
State string
|
|
RunnerID string
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
Logs []LogEntry
|
|
Artifacts []ArtifactEntry
|
|
}
|
|
|
|
func (e *Execution) transitionTo(newState string, updatedAt time.Time) 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 = updatedAt
|
|
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),
|
|
}
|
|
}
|
|
|
|
// NextExecutionID returns the next execution identifier using the store clock.
|
|
func (s *Store) NextExecutionID() string {
|
|
n := s.now()
|
|
return fmt.Sprintf("exec-%d", n.UnixNano())
|
|
}
|
|
|
|
func (s *Store) CreateJob(id, name string, runInput *RunInput) (*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,
|
|
RunInput: copyRunInput(runInput),
|
|
}
|
|
s.jobs[id] = job
|
|
return copyJob(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)
|
|
}
|
|
|
|
if _, exists := s.executions[execID]; exists {
|
|
return nil, fmt.Errorf("execution %s already exists", execID)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
now := s.now()
|
|
exec.Logs = append(exec.Logs, LogEntry{
|
|
Timestamp: now,
|
|
Line: line,
|
|
})
|
|
exec.UpdatedAt = 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)
|
|
}
|
|
|
|
return job.transitionTo(newState, s.now())
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
return exec.transitionTo(newState, s.now())
|
|
}
|
|
|
|
func (s *Store) CancelJobExecution(jobID, execID string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
job, ok := s.jobs[jobID]
|
|
if !ok {
|
|
return fmt.Errorf("job not found: %s", jobID)
|
|
}
|
|
|
|
exec, ok := s.executions[execID]
|
|
if !ok {
|
|
return fmt.Errorf("execution not found: %s", execID)
|
|
}
|
|
|
|
if exec.JobID != jobID || job.ExecutionID != execID {
|
|
return fmt.Errorf("job and execution mismatch")
|
|
}
|
|
|
|
isTerminal := func(state string) bool {
|
|
return state == StateSucceeded || state == StateFailed || state == StateCanceled
|
|
}
|
|
if isTerminal(job.State) || isTerminal(exec.State) {
|
|
return fmt.Errorf("cannot cancel terminal state (job: %s, execution: %s)", job.State, exec.State)
|
|
}
|
|
|
|
now := s.now()
|
|
if err := job.transitionTo(StateCanceled, now); err != nil {
|
|
return err
|
|
}
|
|
if err := exec.transitionTo(StateCanceled, now); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) SetExecutionRunnerID(execID, runnerID 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.RunnerID = runnerID
|
|
exec.UpdatedAt = s.now()
|
|
return nil
|
|
}
|
|
|
|
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
|
|
cp.RunInput = copyRunInput(j.RunInput)
|
|
return &cp
|
|
}
|
|
|
|
func copyRunInput(in *RunInput) *RunInput {
|
|
if in == nil {
|
|
return nil
|
|
}
|
|
cp := *in
|
|
if in.Variables != nil {
|
|
cp.Variables = make(map[string]string, len(in.Variables))
|
|
for k, v := range in.Variables {
|
|
cp.Variables[k] = v
|
|
}
|
|
}
|
|
if in.CommandTypes != nil {
|
|
cp.CommandTypes = make([]string, len(in.CommandTypes))
|
|
copy(cp.CommandTypes, in.CommandTypes)
|
|
}
|
|
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
|
|
}
|