// Copyright 2022 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 linux // +build linux package local import ( "context" "fmt" "io" "os" "os/exec" "path/filepath" "runtime" "slices" "strings" "sync" "sync/atomic" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" ) func TestIsAvailable(t *testing.T) { t.Run("not available in container", func(t *testing.T) { backend := New() t.Setenv("WOODPECKER_IN_CONTAINER", "true") available := backend.IsAvailable(context.Background()) assert.False(t, available) }) t.Run("available without container env and no cli context", func(t *testing.T) { backend := New() os.Unsetenv("WOODPECKER_IN_CONTAINER") available := backend.IsAvailable(context.Background()) assert.True(t, available) }) } func TestLoad(t *testing.T) { backend, _ := New().(*local) t.Run("load without cli context", func(t *testing.T) { ctx := context.Background() info, err := backend.Load(ctx) require.NoError(t, err) assert.NotNil(t, info) assert.Equal(t, runtime.GOOS+"/"+runtime.GOARCH, info.Platform) }) t.Run("load with cli context and temp dir", func(t *testing.T) { tmpDir := t.TempDir() cmd := &cli.Command{} cmd.Flags = []cli.Flag{ &cli.StringFlag{ Name: "backend-local-temp-dir", Value: tmpDir, }, } ctx := context.WithValue(context.Background(), types.CliCommand, cmd) info, err := backend.Load(ctx) require.NoError(t, err) assert.NotNil(t, info) assert.Equal(t, tmpDir, backend.tempDir) assert.Equal(t, runtime.GOOS+"/"+runtime.GOARCH, info.Platform) }) } func TestSetupWorkflow(t *testing.T) { backend, _ := New().(*local) backend.tempDir = t.TempDir() ctx := context.Background() taskUUID := "test-task-uuid-123" config := &types.Config{} err := backend.SetupWorkflow(ctx, config, taskUUID) require.NoError(t, err) // Verify state was saved state, err := backend.getWorkflowState(taskUUID) require.NoError(t, err) assert.NotNil(t, state) assert.NotEmpty(t, state.baseDir) assert.NotEmpty(t, state.workspaceDir) assert.NotEmpty(t, state.homeDir) // Verify directories were created assert.DirExists(t, state.baseDir) assert.DirExists(t, state.workspaceDir) assert.DirExists(t, state.homeDir) // Verify directory structure assert.Equal(t, filepath.Join(state.baseDir, "workspace"), state.workspaceDir) assert.Equal(t, filepath.Join(state.baseDir, "home"), state.homeDir) // Cleanup assert.NoError(t, os.RemoveAll(state.baseDir)) } func TestDestroyWorkflow(t *testing.T) { backend, _ := New().(*local) backend.tempDir = t.TempDir() ctx := context.Background() taskUUID := "test-destroy-task" config := &types.Config{} // Setup workflow first err := backend.SetupWorkflow(ctx, config, taskUUID) require.NoError(t, err) state, err := backend.getWorkflowState(taskUUID) require.NoError(t, err) baseDir := state.baseDir // Verify directory exists assert.DirExists(t, baseDir) // Destroy workflow err = backend.DestroyWorkflow(ctx, config, taskUUID) require.NoError(t, err) // Verify directory was removed assert.NoDirExists(t, baseDir) // Verify state was deleted _, err = backend.getWorkflowState(taskUUID) assert.ErrorIs(t, err, ErrWorkflowStateNotFound) } func prepairEnv(t *testing.T) { prevEnv := os.Environ() os.Clearenv() t.Cleanup(func() { for i := range prevEnv { env := strings.SplitN(prevEnv[i], "=", 2) //nolint:usetesting // reason: the suggested t.Setenv will be undone on t.Run() end witch we explizite dont want here _ = os.Setenv(env[0], env[1]) } }) } func TestRunStep(t *testing.T) { if runtime.GOOS != "linux" { t.Skip("skipping on non linux due to shell availability and symlink capability") } // we lookup shell tools we use first and create the PATH var based on that shBinary, err := exec.LookPath("sh") require.NoError(t, err) path := []string{filepath.Dir(shBinary)} echoBinary, err := exec.LookPath("echo") require.NoError(t, err) if echoPath := filepath.Dir(echoBinary); !slices.Contains(path, echoPath) { path = append(path, echoPath) } // we make a symlinc to have a posix but non default shell altShellDir := t.TempDir() altShellPath := filepath.Join(altShellDir, "altsh") require.NoError(t, os.Symlink(shBinary, altShellPath)) path = append(path, altShellDir) prepairEnv(t) //nolint:usetesting // reason: we use prepairEnv() os.Setenv("PATH", strings.Join(path, ":")) backend, _ := New().(*local) backend.tempDir = t.TempDir() ctx := t.Context() taskUUID := "test-run-tasks" // Setup workflow require.NoError(t, backend.SetupWorkflow(ctx, &types.Config{}, taskUUID)) t.Run("type commands", func(t *testing.T) { step := &types.Step{ UUID: "step-1", Name: "test-step", Type: types.StepTypeCommands, Image: "sh", Commands: []string{"echo hello", "env"}, Environment: map[string]string{ "TEST_VAR": "test_value", }, } t.Run("start successful", func(t *testing.T) { err = backend.StartStep(ctx, step, taskUUID) require.NoError(t, err) // Verify command was started state, err := backend.getWorkflowState(taskUUID) require.NoError(t, err) stepStateWraped, contains := state.stepState.Load(step.UUID) assert.True(t, contains) stepState, _ := stepStateWraped.(*stepState) assert.NotNil(t, stepState.cmd) var outputData []byte outputDataMutex := sync.Mutex{} go t.Run("TailStep", func(t *testing.T) { outputDataMutex.Lock() go outputDataMutex.Unlock() output, err := backend.TailStep(ctx, step, taskUUID) require.NoError(t, err) assert.NotNil(t, output) // Read output outputData, err = io.ReadAll(output) require.NoError(t, err) }) // Wait for step to finish t.Run("TestWaitStep", func(t *testing.T) { time.Sleep(time.Second / 5) // needed to prevent race condition on outputData state, err := backend.WaitStep(ctx, step, taskUUID) require.NoError(t, err) assert.True(t, state.Exited) assert.Equal(t, 0, state.ExitCode) }) // Verify output outputDataMutex.Lock() go outputDataMutex.Unlock() outputLines := strings.Split(strings.TrimSpace(string(outputData)), "\n") require.Truef(t, len(outputLines) > 3, "output of lines must be bigger than 3 at least but we got: %#v", outputLines) // we first test output without environments wantBeforeEnvs := []string{ "+ echo hello", "hello", "+ env", } gotBeforeEnvs := outputLines[:len(wantBeforeEnvs)] assert.Equal(t, wantBeforeEnvs, gotBeforeEnvs) // we filter out nixos specific stuff catched up in env output gotEnvs := slices.DeleteFunc(outputLines[len(wantBeforeEnvs):], func(s string) bool { return strings.HasPrefix(s, "_=") || strings.HasPrefix(s, "SHLVL=") }) assert.ElementsMatch(t, []string{ "PWD=" + state.baseDir + "/workspace", "USERPROFILE=" + state.baseDir + "/home", "TEST_VAR=test_value", "HOME=" + state.baseDir + "/home", "CI_WORKSPACE=" + state.baseDir + "/workspace", "PATH=" + strings.Join(path, ":"), }, gotEnvs) t.Run("TestDestroyStep", func(t *testing.T) { err := backend.DestroyStep(ctx, step, taskUUID) require.NoError(t, err) }) }) }) t.Run("run command in alternate unix shell", func(t *testing.T) { step := &types.Step{ UUID: "step-altshell", Name: "altshell", Type: types.StepTypeCommands, Image: "altsh", Commands: []string{"echo success"}, } err = backend.StartStep(ctx, step, taskUUID) require.NoError(t, err) state, err := backend.WaitStep(ctx, step, taskUUID) require.NoError(t, err) assert.True(t, state.Exited) assert.Equal(t, 0, state.ExitCode) }) t.Run("command should fail", func(t *testing.T) { step := &types.Step{ UUID: "step-fail", Name: "fail-step", Type: types.StepTypeCommands, Image: "sh", Commands: []string{"exit 1"}, } err = backend.StartStep(ctx, step, taskUUID) require.NoError(t, err) state, err := backend.WaitStep(ctx, step, taskUUID) require.NoError(t, err) assert.True(t, state.Exited) assert.Equal(t, 1, state.ExitCode) }) t.Run("WaitStep", func(t *testing.T) { t.Run("step not found", func(t *testing.T) { step := &types.Step{ UUID: "nonexistent-step", Name: "missing", } _, err = backend.WaitStep(ctx, step, taskUUID) assert.Error(t, err) assert.Contains(t, err.Error(), "not found") }) }) t.Run("type plugin", func(t *testing.T) { step := &types.Step{ UUID: "step-plugin-1", Name: "test-plugin", Type: types.StepTypePlugin, Image: "echo", // Use a binary that exists Environment: map[string]string{}, } t.Run("start", func(t *testing.T) { err = backend.StartStep(ctx, step, taskUUID) require.NoError(t, err) // Verify command was started state, err := backend.getStepState(taskUUID, step.UUID) require.NoError(t, err) assert.NotEqualf(t, 0, state.cmd.Process.Pid, "expect an pid of the process") }) }) t.Run("type unsupported", func(t *testing.T) { step := &types.Step{ UUID: "step-unsupported", Name: "test-unsupported", Type: "unsupported-type", } t.Run("start", func(t *testing.T) { err = backend.StartStep(ctx, step, taskUUID) assert.ErrorIs(t, err, ErrUnsupportedStepType) }) }) // Cleanup assert.NoError(t, backend.DestroyWorkflow(ctx, &types.Config{}, taskUUID)) } func TestStateManagement(t *testing.T) { backend, _ := New().(*local) t.Run("save and get state", func(t *testing.T) { taskUUID := "test-state-uuid" state := &workflowState{ baseDir: "/tmp/test", homeDir: "/tmp/test/2home", workspaceDir: "/tmp/test/2workspace", } backend.workflows.Store(taskUUID, state) retrieved, err := backend.getWorkflowState(taskUUID) require.NoError(t, err) assert.Equal(t, state.baseDir, retrieved.baseDir) assert.Equal(t, state.homeDir, retrieved.homeDir) assert.Equal(t, state.workspaceDir, retrieved.workspaceDir) }) t.Run("get nonexistent state", func(t *testing.T) { _, err := backend.getWorkflowState("nonexistent-uuid") assert.ErrorIs(t, err, ErrWorkflowStateNotFound) }) t.Run("delete state", func(t *testing.T) { taskUUID := "test-delete-uuid" state := &workflowState{} backend.workflows.Store(taskUUID, state) // Verify state exists _, err := backend.getWorkflowState(taskUUID) require.NoError(t, err) // Delete state backend.workflows.Delete(taskUUID) // Verify state is gone _, err = backend.getWorkflowState(taskUUID) assert.ErrorIs(t, err, ErrWorkflowStateNotFound) }) } func TestConcurrentWorkflows(t *testing.T) { backend, _ := New().(*local) backend.tempDir = t.TempDir() ctx := context.Background() // Create multiple workflows concurrently taskUUIDs := []string{"task-1", "task-2", "task-3"} for _, uuid := range taskUUIDs { err := backend.SetupWorkflow(ctx, &types.Config{}, uuid) require.NoError(t, err) } counter := atomic.Int32{} counter.Store(0) for _, uuid := range taskUUIDs { go t.Run("start step in "+uuid, func(t *testing.T) { for i := 0; i < 3; i++ { counter.Store(counter.Load() + 1) step := &types.Step{ UUID: fmt.Sprintf("step-%s-%d", uuid, i), Name: fmt.Sprintf("step-name-%s-%d", uuid, i), Type: types.StepTypePlugin, Image: "sh", Commands: []string{fmt.Sprintf("echo %s %d", uuid, i)}, Environment: map[string]string{}, } require.NoError(t, backend.StartStep(ctx, step, uuid)) _, err := backend.WaitStep(ctx, step, uuid) require.NoError(t, err) counter.Store(counter.Load() - 1) } }) } // Verify all states exist for _, uuid := range taskUUIDs { state, err := backend.getWorkflowState(uuid) require.NoError(t, err) assert.NotNil(t, state) } failSave := 0 loop: for { if failSave == 1000 { // wait max 1s t.Log("failSave was hit") t.FailNow() } failSave++ select { case <-time.After(time.Millisecond): if count := counter.Load(); count == 0 { break loop } else { t.Logf("count at: %d", count) } case <-ctx.Done(): return } } // Cleanup all workflows for _, uuid := range taskUUIDs { // Cleanup all steps for i := 0; i < 3; i++ { stepUUID := fmt.Sprintf("step-%s-%d", uuid, i) assert.NoError(t, backend.DestroyStep(ctx, &types.Step{UUID: stepUUID}, uuid)) } // finish with workflow cleanup err := backend.DestroyWorkflow(ctx, &types.Config{}, uuid) require.NoError(t, err) } // Verify all states are deleted for _, uuid := range taskUUIDs { _, err := backend.getWorkflowState(uuid) assert.ErrorIs(t, err, ErrWorkflowStateNotFound) } }