You've already forked woodpecker
mirror of
https://github.com/woodpecker-ci/woodpecker.git
synced 2026-06-03 16:35:37 +02:00
60df1c618d
because we now wait for all steps to trace status back before we return, the defere did not tear down services anymore ... ... we now explicit tear down services and steps after all stages have executed. Also adds tests to check for that and update the dummy backend to fullfill the interface contract of killing all "running" steps with DestroyWorkflow.
392 lines
11 KiB
Go
392 lines
11 KiB
Go
// Copyright 2026 Woodpecker Authors
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
//go:build test
|
|
|
|
package runtime
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/mock"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/dummy"
|
|
backend_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types"
|
|
"go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types/mocks"
|
|
pipeline_errors "go.woodpecker-ci.org/woodpecker/v3/pipeline/errors"
|
|
tracer_mocks "go.woodpecker-ci.org/woodpecker/v3/pipeline/tracing/mocks"
|
|
)
|
|
|
|
func TestRunNilTracer(t *testing.T) {
|
|
t.Parallel()
|
|
r := New(&backend_types.Config{}, dummy.New(), WithLogger(newTestLogger(t)), WithTracer(nil))
|
|
|
|
err := r.Run(t.Context())
|
|
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "tracer must not be nil")
|
|
}
|
|
|
|
func TestRunSuccess(t *testing.T) {
|
|
t.Parallel()
|
|
tracer := newTestTracer(t)
|
|
r := New(
|
|
&backend_types.Config{
|
|
Stages: []*backend_types.Stage{{
|
|
Steps: []*backend_types.Step{{
|
|
Name: "build", UUID: "u1",
|
|
Type: backend_types.StepTypeCommands, OnSuccess: true,
|
|
Environment: map[string]string{}, Commands: []string{"echo hello"},
|
|
}},
|
|
}},
|
|
},
|
|
dummy.New(),
|
|
WithTracer(tracer),
|
|
WithLogger(newTestLogger(t)),
|
|
)
|
|
|
|
err := r.Run(t.Context())
|
|
|
|
assert.NoError(t, err)
|
|
calls := getTracerStates(tracer)
|
|
require.Len(t, calls, 2)
|
|
}
|
|
|
|
func TestRunMultipleStages(t *testing.T) {
|
|
t.Parallel()
|
|
tracer := newTestTracer(t)
|
|
r := New(
|
|
&backend_types.Config{
|
|
Stages: []*backend_types.Stage{
|
|
{Steps: []*backend_types.Step{{
|
|
Name: "stage1", UUID: "u1",
|
|
Type: backend_types.StepTypeCommands, OnSuccess: true,
|
|
Environment: map[string]string{}, Commands: []string{"echo 1"},
|
|
}}},
|
|
{Steps: []*backend_types.Step{{
|
|
Name: "stage2", UUID: "u2",
|
|
Type: backend_types.StepTypeCommands, OnSuccess: true,
|
|
Environment: map[string]string{}, Commands: []string{"echo 2"},
|
|
}}},
|
|
},
|
|
},
|
|
dummy.New(),
|
|
WithTracer(tracer),
|
|
WithLogger(newTestLogger(t)),
|
|
)
|
|
|
|
err := r.Run(t.Context())
|
|
|
|
assert.NoError(t, err)
|
|
calls := getTracerStates(tracer)
|
|
require.Len(t, calls, 4)
|
|
}
|
|
|
|
func TestRunStepError(t *testing.T) {
|
|
t.Parallel()
|
|
tracer := newTestTracer(t)
|
|
r := New(
|
|
&backend_types.Config{
|
|
Stages: []*backend_types.Stage{{
|
|
Steps: []*backend_types.Step{{
|
|
Name: "fail", UUID: "u1",
|
|
Type: backend_types.StepTypeCommands, OnSuccess: true,
|
|
Environment: map[string]string{dummy.EnvKeyStepExitCode: "1"},
|
|
Commands: []string{"exit 1"},
|
|
}},
|
|
}},
|
|
},
|
|
dummy.New(),
|
|
WithTracer(tracer),
|
|
WithLogger(newTestLogger(t)),
|
|
)
|
|
|
|
err := r.Run(t.Context())
|
|
|
|
assert.Error(t, err)
|
|
var exitErr *pipeline_errors.ExitError
|
|
assert.True(t, errors.As(err, &exitErr))
|
|
assert.Equal(t, 1, exitErr.Code)
|
|
}
|
|
|
|
func TestRunContextCanceled(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, cancel := context.WithCancelCause(t.Context())
|
|
cancel(nil)
|
|
|
|
r := New(
|
|
&backend_types.Config{
|
|
Stages: []*backend_types.Stage{{
|
|
Steps: []*backend_types.Step{{
|
|
Name: "s1", UUID: "u1",
|
|
Type: backend_types.StepTypeCommands, OnSuccess: true,
|
|
Environment: map[string]string{}, Commands: []string{"echo hello"},
|
|
}},
|
|
}},
|
|
},
|
|
dummy.New(),
|
|
WithTracer(newTestTracer(t)),
|
|
WithContext(ctx),
|
|
WithLogger(newTestLogger(t)),
|
|
)
|
|
|
|
err := r.Run(t.Context())
|
|
|
|
assert.ErrorIs(t, err, pipeline_errors.ErrCancel)
|
|
}
|
|
|
|
func TestRunSetupWorkflowError(t *testing.T) {
|
|
t.Parallel()
|
|
r := New(
|
|
&backend_types.Config{},
|
|
dummy.New(),
|
|
WithTracer(newTestTracer(t)),
|
|
WithTaskUUID(dummy.WorkflowSetupFailUUID),
|
|
WithLogger(newTestLogger(t)),
|
|
)
|
|
|
|
err := r.Run(t.Context())
|
|
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestRunSetupWorkflowInvalidSetupError(t *testing.T) {
|
|
t.Parallel()
|
|
tracer := newTestTracer(t)
|
|
step := &backend_types.Step{Name: "clone", UUID: "clone-uuid"}
|
|
setupErr := &pipeline_errors.ErrInvalidWorkflowSetup{
|
|
Err: errors.New("bad image"),
|
|
Step: step,
|
|
}
|
|
engine := mocks.NewMockBackend(t)
|
|
engine.On("SetupWorkflow", mock.Anything, mock.Anything, mock.Anything).Return(setupErr)
|
|
engine.On("DestroyWorkflow", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
|
|
|
r := New(&backend_types.Config{}, engine, WithTracer(tracer), WithLogger(newTestLogger(t)))
|
|
|
|
err := r.Run(t.Context())
|
|
|
|
assert.Error(t, err)
|
|
calls := getTracerStates(tracer)
|
|
require.Len(t, calls, 1)
|
|
assert.Equal(t, step, calls[0].CurrStep)
|
|
assert.True(t, calls[0].CurrStepState.Exited)
|
|
assert.Equal(t, 1, calls[0].CurrStepState.ExitCode)
|
|
}
|
|
|
|
func TestRunDestroyWorkflowAlwaysCalled(t *testing.T) {
|
|
t.Parallel()
|
|
var destroyed int32
|
|
engine := mocks.NewMockBackend(t)
|
|
engine.On("SetupWorkflow", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
|
engine.On("DestroyWorkflow", mock.Anything, mock.Anything, mock.Anything).
|
|
Run(func(_ mock.Arguments) { atomic.AddInt32(&destroyed, 1) }).Return(nil)
|
|
|
|
r := New(&backend_types.Config{}, engine, WithTracer(newTestTracer(t)), WithLogger(newTestLogger(t)))
|
|
|
|
_ = r.Run(t.Context())
|
|
|
|
assert.Equal(t, int32(1), atomic.LoadInt32(&destroyed))
|
|
}
|
|
|
|
func TestRunDestroyWorkflowCalledOnSetupError(t *testing.T) {
|
|
t.Parallel()
|
|
var destroyed int32
|
|
engine := mocks.NewMockBackend(t)
|
|
engine.On("SetupWorkflow", mock.Anything, mock.Anything, mock.Anything).
|
|
Return(errors.New("setup boom"))
|
|
engine.On("DestroyWorkflow", mock.Anything, mock.Anything, mock.Anything).
|
|
Run(func(_ mock.Arguments) { atomic.AddInt32(&destroyed, 1) }).Return(nil)
|
|
|
|
r := New(&backend_types.Config{}, engine, WithTracer(newTestTracer(t)), WithLogger(newTestLogger(t)))
|
|
|
|
_ = r.Run(t.Context())
|
|
|
|
assert.Equal(t, int32(1), atomic.LoadInt32(&destroyed))
|
|
}
|
|
|
|
func TestTraceWorkflowSetupError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("MatchingError", func(t *testing.T) {
|
|
t.Parallel()
|
|
tracer := newTestTracer(t)
|
|
r := New(&backend_types.Config{}, dummy.New(), WithTracer(tracer), WithLogger(newTestLogger(t)))
|
|
step := &backend_types.Step{Name: "setup", UUID: "su"}
|
|
err := &pipeline_errors.ErrInvalidWorkflowSetup{Err: errors.New("bad"), Step: step}
|
|
|
|
r.traceWorkflowSetupError(err)
|
|
|
|
calls := getTracerStates(tracer)
|
|
require.Len(t, calls, 1)
|
|
assert.Equal(t, step, calls[0].CurrStep)
|
|
assert.True(t, calls[0].CurrStepState.Exited)
|
|
assert.Equal(t, 1, calls[0].CurrStepState.ExitCode)
|
|
})
|
|
|
|
t.Run("NonMatchingError", func(t *testing.T) {
|
|
t.Parallel()
|
|
tracer := tracer_mocks.NewMockTracer(t)
|
|
// Trace should NOT be called — no .On() setup means test panics if called.
|
|
r := New(&backend_types.Config{}, dummy.New(), WithTracer(tracer), WithLogger(newTestLogger(t)))
|
|
|
|
r.traceWorkflowSetupError(errors.New("generic error"))
|
|
})
|
|
|
|
t.Run("TracerFailure", func(t *testing.T) {
|
|
t.Parallel()
|
|
tracer := tracer_mocks.NewMockTracer(t)
|
|
tracer.On("Trace", mock.Anything).Return(errors.New("trace failed"))
|
|
r := New(&backend_types.Config{}, dummy.New(), WithTracer(tracer), WithLogger(newTestLogger(t)))
|
|
step := &backend_types.Step{Name: "setup", UUID: "su"}
|
|
|
|
// Should not panic — the error is logged, not returned.
|
|
r.traceWorkflowSetupError(&pipeline_errors.ErrInvalidWorkflowSetup{
|
|
Err: errors.New("bad"), Step: step,
|
|
})
|
|
})
|
|
}
|
|
|
|
func TestRunStage(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("ParallelExecution", func(t *testing.T) {
|
|
t.Parallel()
|
|
tracer := newTestTracer(t)
|
|
r := newDummyRuntime(t, tracer)
|
|
|
|
steps := []*backend_types.Step{
|
|
{Name: "a", UUID: "ua", Type: backend_types.StepTypeCommands, OnSuccess: true, Environment: map[string]string{}, Commands: []string{"echo a"}},
|
|
{Name: "b", UUID: "ub", Type: backend_types.StepTypeCommands, OnSuccess: true, Environment: map[string]string{}, Commands: []string{"echo b"}},
|
|
{Name: "c", UUID: "uc", Type: backend_types.StepTypeCommands, OnSuccess: true, Environment: map[string]string{}, Commands: []string{"echo c"}},
|
|
}
|
|
|
|
err := <-r.runStage(t.Context(), steps)
|
|
|
|
assert.NoError(t, err)
|
|
assert.Len(t, getTracerStates(tracer), 6)
|
|
})
|
|
|
|
t.Run("OneStepFails", func(t *testing.T) {
|
|
t.Parallel()
|
|
tracer := newTestTracer(t)
|
|
r := newDummyRuntime(t, tracer)
|
|
|
|
steps := []*backend_types.Step{
|
|
{Name: "good", UUID: "ug", Type: backend_types.StepTypeCommands, OnSuccess: true, Environment: map[string]string{}, Commands: []string{"echo ok"}},
|
|
{Name: "bad", UUID: "ub", Type: backend_types.StepTypeCommands, OnSuccess: true, Environment: map[string]string{dummy.EnvKeyStepExitCode: "1"}, Commands: []string{"exit 1"}},
|
|
}
|
|
|
|
err := <-r.runStage(t.Context(), steps)
|
|
|
|
assert.Error(t, err)
|
|
})
|
|
}
|
|
|
|
func TestNewDefaults(t *testing.T) {
|
|
t.Parallel()
|
|
spec := &backend_types.Config{}
|
|
|
|
r := New(spec, dummy.New())
|
|
|
|
assert.Equal(t, spec, r.spec)
|
|
assert.NotEmpty(t, r.taskUUID)
|
|
assert.NotNil(t, r.ctx)
|
|
assert.NotNil(t, r.tracer)
|
|
assert.NotNil(t, r.engine)
|
|
assert.NoError(t, r.err.Get())
|
|
}
|
|
|
|
func TestWithOptions(t *testing.T) {
|
|
t.Parallel()
|
|
engine := dummy.New()
|
|
tracer := newTestTracer(t)
|
|
ctx := context.Background()
|
|
desc := map[string]string{"repo": "test"}
|
|
|
|
r := New(&backend_types.Config{},
|
|
engine,
|
|
WithTracer(tracer),
|
|
WithContext(ctx),
|
|
WithDescription(desc),
|
|
WithTaskUUID("custom-uuid"),
|
|
WithLogger(newTestLogger(t)),
|
|
)
|
|
|
|
assert.Equal(t, engine, r.engine)
|
|
assert.Equal(t, tracer, r.tracer)
|
|
assert.Equal(t, ctx, r.ctx)
|
|
assert.Equal(t, "custom-uuid", r.taskUUID)
|
|
assert.Equal(t, "test", r.description["repo"])
|
|
}
|
|
|
|
func TestGetShutdownCtx(t *testing.T) {
|
|
ctx := GetShutdownCtx()
|
|
assert.NotNil(t, ctx)
|
|
|
|
ctx2 := GetShutdownCtx()
|
|
assert.Equal(t, ctx, ctx2)
|
|
}
|
|
|
|
// Gap A: logger == nil guard.
|
|
func TestRunNilLogger(t *testing.T) {
|
|
t.Parallel()
|
|
r := New(&backend_types.Config{},
|
|
dummy.New(),
|
|
WithTracer(newTestTracer(t)),
|
|
// WithLogger intentionally omitted
|
|
)
|
|
|
|
err := r.Run(t.Context())
|
|
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "logger must not be nil")
|
|
}
|
|
|
|
// Gap B: runnerCtx is already done inside the defer → GetShutdownCtx() fallback.
|
|
func TestRunDestroyWorkflowFallsBackToShutdownCtx(t *testing.T) {
|
|
t.Parallel()
|
|
engine := mocks.NewMockBackend(t)
|
|
engine.On("SetupWorkflow", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
|
|
|
var destroyCtx context.Context
|
|
engine.On("DestroyWorkflow", mock.Anything, mock.Anything, mock.Anything).
|
|
Run(func(args mock.Arguments) {
|
|
destroyCtx, _ = args.Get(0).(context.Context)
|
|
}).Return(nil)
|
|
|
|
// Pass a pre-canceled runnerCtx so ctx.Err() != nil in the defer.
|
|
runnerCtx, cancel := context.WithCancelCause(context.Background())
|
|
cancel(nil)
|
|
|
|
r := New(&backend_types.Config{},
|
|
engine,
|
|
WithTracer(newTestTracer(t)),
|
|
WithLogger(newTestLogger(t)),
|
|
)
|
|
|
|
_ = r.Run(runnerCtx)
|
|
|
|
require.NotNil(t, destroyCtx)
|
|
// The shutdown context is not the canceled runnerCtx — it must still be valid
|
|
// (or at least not the same canceled one).
|
|
assert.NotEqual(t, runnerCtx, destroyCtx,
|
|
"DestroyWorkflow should receive the shutdown fallback context, not the canceled runnerCtx")
|
|
}
|