384 lines
7.8 KiB
Go
384 lines
7.8 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),
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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
|
|
}
|