This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / spindle / engine / engine_test.go
7.2 kB 266 lines
1package engine 2 3import ( 4 "context" 5 "log/slog" 6 "os" 7 "path/filepath" 8 "sync" 9 "testing" 10 "time" 11 12 "tangled.org/core/api/tangled" 13 "tangled.org/core/spindle/config" 14 "tangled.org/core/spindle/db" 15 "tangled.org/core/spindle/models" 16 "tangled.org/core/spindle/secrets" 17) 18 19type mockStep struct { 20 name string 21 command string 22} 23 24func (m mockStep) Name() string { return m.name } 25func (m mockStep) Command() string { return m.command } 26func (m mockStep) Kind() models.StepKind { return models.StepKindUser } 27 28type mockEngine struct { 29 mu sync.Mutex 30 setupCalls []models.WorkflowId 31 runStepCalls []models.WorkflowId 32 setupFunc func(ctx context.Context, wid models.WorkflowId) error 33 runStepFunc func(ctx context.Context, wid models.WorkflowId, idx int) error 34 timeout time.Duration 35} 36 37func (m *mockEngine) InitWorkflow(twf tangled.Pipeline_Workflow, tpl tangled.Pipeline) (*models.Workflow, error) { 38 return &models.Workflow{}, nil 39} 40 41func (m *mockEngine) SetupWorkflow(ctx context.Context, wid models.WorkflowId, wf *models.Workflow, wfLogger models.WorkflowLogger) error { 42 m.mu.Lock() 43 m.setupCalls = append(m.setupCalls, wid) 44 fn := m.setupFunc 45 m.mu.Unlock() 46 if fn != nil { 47 return fn(ctx, wid) 48 } 49 return nil 50} 51 52func (m *mockEngine) WorkflowTimeout() time.Duration { 53 if m.timeout != 0 { 54 return m.timeout 55 } 56 return 5 * time.Second 57} 58 59func (m *mockEngine) DestroyWorkflow(ctx context.Context, wid models.WorkflowId) error { 60 return nil 61} 62 63func (m *mockEngine) RunStep(ctx context.Context, wid models.WorkflowId, w *models.Workflow, idx int, secrets []secrets.UnlockedSecret, wfLogger models.WorkflowLogger) error { 64 m.mu.Lock() 65 m.runStepCalls = append(m.runStepCalls, wid) 66 fn := m.runStepFunc 67 m.mu.Unlock() 68 69 if fn != nil { 70 return fn(ctx, wid, idx) 71 } 72 return nil 73} 74 75func newTestDB(t *testing.T) *db.DB { 76 t.Helper() 77 d, err := db.Make(context.Background(), filepath.Join(t.TempDir(), "spindle.db")) 78 if err != nil { 79 t.Fatalf("failed to create test db: %v", err) 80 } 81 return d 82} 83 84func TestStartWorkflows_CollisionRejection(t *testing.T) { 85 t.Parallel() 86 87 testDB := newTestDB(t) 88 logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) 89 90 eng := &mockEngine{} 91 pipelineId := models.PipelineId{ 92 Knot: "test-knot", 93 Rkey: "test-rkey", 94 } 95 96 // two names that normalize to the same wid must not both run 97 wfColliding1 := models.Workflow{ 98 Name: "test-job", 99 Steps: []models.Step{mockStep{name: "step1"}}, 100 } 101 wfColliding2 := models.Workflow{ 102 Name: "test job", 103 Steps: []models.Step{mockStep{name: "step1"}}, 104 } 105 wfUnique := models.Workflow{ 106 Name: "unique_job", 107 Steps: []models.Step{mockStep{name: "step1"}}, 108 } 109 110 pipeline := &models.Pipeline{ 111 Workflows: map[models.Engine][]models.Workflow{ 112 eng: {wfColliding1, wfColliding2, wfUnique}, 113 }, 114 } 115 116 cfg := &config.Config{Server: config.Server{LogDir: t.TempDir()}} 117 StartWorkflows(logger, nil, cfg, testDB, nil, context.Background(), pipeline, pipelineId) 118 119 eng.mu.Lock() 120 setupCalls := append([]models.WorkflowId(nil), eng.setupCalls...) 121 eng.mu.Unlock() 122 123 for _, call := range setupCalls { 124 if call.Name == "test-job" || call.Name == "test job" { 125 t.Fatalf("expected colliding workflow %s to not be started", call.Name) 126 } 127 } 128 129 hasUnique := false 130 for _, call := range setupCalls { 131 if call.Name == "unique_job" { 132 hasUnique = true 133 } 134 } 135 if !hasUnique { 136 t.Fatalf("expected unique workflow unique_job to be started") 137 } 138 139 widColliding1 := models.WorkflowId{PipelineId: pipelineId, Name: "test-job"} 140 widColliding2 := models.WorkflowId{PipelineId: pipelineId, Name: "test job"} 141 widUnique := models.WorkflowId{PipelineId: pipelineId, Name: "unique_job"} 142 143 status1, err := testDB.GetStatus(widColliding1) 144 if err != nil || status1.Status != string(models.StatusKindFailed) { 145 t.Fatalf("expected colliding1 status to be failed, got status=%v err=%v", status1, err) 146 } 147 148 status2, err := testDB.GetStatus(widColliding2) 149 if err != nil || status2.Status != string(models.StatusKindFailed) { 150 t.Fatalf("expected colliding2 status to be failed, got status=%v err=%v", status2, err) 151 } 152 153 statusUnique, err := testDB.GetStatus(widUnique) 154 if err != nil || statusUnique.Status != string(models.StatusKindSuccess) { 155 t.Fatalf("expected unique status to be success, got status=%v err=%v", statusUnique, err) 156 } 157} 158 159func TestCancelWorkflow_NotOverwritten(t *testing.T) { 160 t.Parallel() 161 162 testDB := newTestDB(t) 163 logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) 164 165 stepStarted := make(chan struct{}) 166 eng := &mockEngine{ 167 runStepFunc: func(ctx context.Context, wid models.WorkflowId, idx int) error { 168 close(stepStarted) 169 <-ctx.Done() 170 return ctx.Err() 171 }, 172 } 173 174 pipelineId := models.PipelineId{ 175 Knot: "test-knot", 176 Rkey: "test-rkey", 177 } 178 179 wid := models.WorkflowId{ 180 PipelineId: pipelineId, 181 Name: "cancel_test_job", 182 } 183 184 pipeline := &models.Pipeline{ 185 Workflows: map[models.Engine][]models.Workflow{ 186 eng: { 187 { 188 Name: "cancel_test_job", 189 Steps: []models.Step{mockStep{name: "step1"}}, 190 }, 191 }, 192 }, 193 } 194 195 cfg := &config.Config{Server: config.Server{LogDir: t.TempDir()}} 196 doneChan := make(chan struct{}) 197 go func() { 198 StartWorkflows(logger, nil, cfg, testDB, nil, context.Background(), pipeline, pipelineId) 199 close(doneChan) 200 }() 201 202 select { 203 case <-stepStarted: 204 case <-time.After(5 * time.Second): 205 t.Fatal("timed out waiting for step to start") 206 } 207 208 _ = testDB.StatusCancelled(wid, "User canceled the workflow", -1, nil) 209 CancelWorkflow(wid) 210 211 select { 212 case <-doneChan: 213 case <-time.After(5 * time.Second): 214 t.Fatal("timed out waiting for StartWorkflows to complete") 215 } 216 217 // the runner writes StatusCancelled itself when it sees the canceled ctx 218 // the handler writes nothing for a live wf, so nothing lands after to overwrite it 219 st, err := testDB.GetStatus(wid) 220 if err != nil { 221 t.Fatalf("GetStatus error = %v", err) 222 } 223 if st.Status != string(models.StatusKindCancelled) { 224 t.Fatalf("expected status to be cancelled, got %s", st.Status) 225 } 226} 227 228func TestSetupTimeout_ReportsTimeout(t *testing.T) { 229 t.Parallel() 230 231 testDB := newTestDB(t) 232 logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) 233 234 // setup blocks past the workflow timeout, so it should land as timeout not failed 235 eng := &mockEngine{ 236 timeout: 100 * time.Millisecond, 237 setupFunc: func(ctx context.Context, wid models.WorkflowId) error { 238 <-ctx.Done() 239 return ctx.Err() 240 }, 241 } 242 243 pipelineId := models.PipelineId{Knot: "test-knot", Rkey: "test-rkey"} 244 wid := models.WorkflowId{PipelineId: pipelineId, Name: "timeout_job"} 245 246 pipeline := &models.Pipeline{ 247 Workflows: map[models.Engine][]models.Workflow{ 248 eng: {{Name: "timeout_job", Steps: []models.Step{mockStep{name: "step1"}}}}, 249 }, 250 } 251 252 cfg := &config.Config{Server: config.Server{LogDir: t.TempDir()}} 253 StartWorkflows(logger, nil, cfg, testDB, nil, context.Background(), pipeline, pipelineId) 254 255 st, err := testDB.GetStatus(wid) 256 if err != nil { 257 t.Fatalf("GetStatus error = %v", err) 258 } 259 if st.Status != string(models.StatusKindTimeout) { 260 t.Fatalf("expected status to be timeout, got %s", st.Status) 261 } 262 263 if len(eng.runStepCalls) != 0 { 264 t.Fatalf("expected no steps to run after setup timeout, got %d", len(eng.runStepCalls)) 265 } 266}