oto/services/core/internal/cicdstate/store.go
toki 88c8ff3b07 feat: runner proto socket transport hardening and related updates
- Add runner-proto-socket-transport-hardening milestone and SDD docs
- Add runnersocket package for Go service
- Update agent config, runner, and job client (Dart)
- Update Bootstrap scripts (PowerShell, shell)
- Update Go service HTTP server handlers and routes
- Add CICD state store updates
- Update agent-ops domain rules and phase roadmap
2026-06-20 18:23:30 +09:00

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 the
// runner transport can dispatch it later. It deliberately avoids any protobuf
// dependency; conversion to/from otopb.RunRequest happens at the HTTP or socket
// 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
}