diff --git a/tests/integration/actions_job_test.go b/tests/integration/actions_job_test.go new file mode 100644 index 00000000000..c72ddc68abe --- /dev/null +++ b/tests/integration/actions_job_test.go @@ -0,0 +1,332 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "encoding/base64" + "fmt" + "net/http" + "net/url" + "testing" + "time" + + actions_model "code.gitea.io/gitea/models/actions" + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + + runnerv1 "code.gitea.io/actions-proto-go/runner/v1" + "github.com/stretchr/testify/assert" +) + +func TestJobWithNeeds(t *testing.T) { + testCases := []struct { + treePath string + fileContent string + execPolicies map[string]*taskExecPolicy + expectedStatuses map[string]string + }{ + { + treePath: ".gitea/workflows/job-with-needs.yml", + fileContent: `name: job-with-needs +on: + push: + paths: + - '.gitea/workflows/job-with-needs.yml' +jobs: + job1: + runs-on: ubuntu-latest + steps: + - run: echo job1 + job2: + runs-on: ubuntu-latest + needs: [job1] + steps: + - run: echo job2 +`, + execPolicies: map[string]*taskExecPolicy{ + "job1": { + result: runnerv1.Result_RESULT_SUCCESS, + }, + "job2": { + result: runnerv1.Result_RESULT_SUCCESS, + }, + }, + expectedStatuses: map[string]string{ + "job1": actions_model.StatusSuccess.String(), + "job2": actions_model.StatusSuccess.String(), + }, + }, + { + treePath: ".gitea/workflows/job-with-needs-fail.yml", + fileContent: `name: job-with-needs-fail +on: + push: + paths: + - '.gitea/workflows/job-with-needs-fail.yml' +jobs: + job1: + runs-on: ubuntu-latest + steps: + - run: echo job1 + job2: + runs-on: ubuntu-latest + needs: [job1] + steps: + - run: echo job2 +`, + execPolicies: map[string]*taskExecPolicy{ + "job1": { + result: runnerv1.Result_RESULT_FAILURE, + }, + }, + expectedStatuses: map[string]string{ + "job1": actions_model.StatusFailure.String(), + "job2": actions_model.StatusSkipped.String(), + }, + }, + { + treePath: ".gitea/workflows/job-with-needs-fail-if.yml", + fileContent: `name: job-with-needs-fail-if +on: + push: + paths: + - '.gitea/workflows/job-with-needs-fail-if.yml' +jobs: + job1: + runs-on: ubuntu-latest + steps: + - run: echo job1 + job2: + runs-on: ubuntu-latest + needs: [job1] + if: ${{ always() }} + steps: + - run: echo job2 +`, + execPolicies: map[string]*taskExecPolicy{ + "job1": { + result: runnerv1.Result_RESULT_FAILURE, + }, + "job2": { + result: runnerv1.Result_RESULT_SUCCESS, + }, + }, + expectedStatuses: map[string]string{ + "job1": actions_model.StatusFailure.String(), + "job2": actions_model.StatusSuccess.String(), + }, + }, + } + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user2.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + + apiRepo := createActionsTestRepo(t, token, "actions-jobs-with-needs", false) + runner := newMockRunner() + runner.registerAsRepoRunner(t, user2.Name, apiRepo.Name, "mock-runner", []string{"ubuntu-latest"}) + + for _, tc := range testCases { + t.Run(fmt.Sprintf("test %s", tc.treePath), func(t *testing.T) { + // create the workflow file + opts := getWorkflowCreateFileOptions(user2, apiRepo.DefaultBranch, fmt.Sprintf("create %s", tc.treePath), tc.fileContent) + fileResp := createWorkflowFile(t, token, user2.Name, apiRepo.Name, tc.treePath, opts) + + // fetch and execute task + for i := 0; i < len(tc.execPolicies); i++ { + task := runner.fetchTask(t) + jobName := getTaskJobNameByTaskID(t, token, user2.Name, apiRepo.Name, task.Id) + policy := tc.execPolicies[jobName] + assert.NotNil(t, policy) + runner.execTask(t, task, policy) + } + + // check result + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/tasks", user2.Name, apiRepo.Name)). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var actionTaskRespAfter api.ActionTaskResponse + DecodeJSON(t, resp, &actionTaskRespAfter) + for _, apiTask := range actionTaskRespAfter.Entries { + if apiTask.HeadSHA != fileResp.Commit.SHA { + continue + } + status := apiTask.Status + assert.Equal(t, status, tc.expectedStatuses[apiTask.Name]) + } + }) + } + }) +} + +func TestJobNeedsMatrix(t *testing.T) { + testCases := []struct { + treePath string + fileContent string + execPolicies map[string]*taskExecPolicy + expectedOutputs map[string]map[string]string // jobID(string) => output(map[string]string) + }{ + { + treePath: ".gitea/workflows/jobs-outputs-with-matrix.yml", + fileContent: `name: jobs-outputs-with-matrix +on: + push: + paths: + - '.gitea/workflows/jobs-outputs-with-matrix.yml' +jobs: + job1: + runs-on: ubuntu-latest + outputs: + output_1: ${{ steps.gen_output.outputs.output_1 }} + output_2: ${{ steps.gen_output.outputs.output_2 }} + output_3: ${{ steps.gen_output.outputs.output_3 }} + strategy: + matrix: + version: [1, 2, 3] + steps: + - name: Generate output + id: gen_output + run: | + version="${{ matrix.version }}" + echo "output_${version}=${version}" >> "$GITHUB_OUTPUT" + job2: + runs-on: ubuntu-latest + needs: [job1] + steps: + - run: echo '${{ toJSON(needs.job1.outputs) }}' +`, + execPolicies: map[string]*taskExecPolicy{ + "job1 (1)": { + result: runnerv1.Result_RESULT_SUCCESS, + outputs: map[string]string{ + "output_1": "1", + "output_2": "", + "output_3": "", + }, + }, + "job1 (2)": { + result: runnerv1.Result_RESULT_SUCCESS, + outputs: map[string]string{ + "output_1": "", + "output_2": "2", + "output_3": "", + }, + }, + "job1 (3)": { + result: runnerv1.Result_RESULT_SUCCESS, + outputs: map[string]string{ + "output_1": "", + "output_2": "", + "output_3": "3", + }, + }, + }, + expectedOutputs: map[string]map[string]string{ + "job1": { + "output_1": "1", + "output_2": "2", + "output_3": "3", + }, + }, + }, + } + onGiteaRun(t, func(t *testing.T, u *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user2.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + + apiRepo := createActionsTestRepo(t, token, "actions-jobs-outputs-with-matrix", false) + runner := newMockRunner() + runner.registerAsRepoRunner(t, user2.Name, apiRepo.Name, "mock-runner", []string{"ubuntu-latest"}) + + for _, tc := range testCases { + t.Run(fmt.Sprintf("test %s", tc.treePath), func(t *testing.T) { + opts := getWorkflowCreateFileOptions(user2, apiRepo.DefaultBranch, fmt.Sprintf("create %s", tc.treePath), tc.fileContent) + createWorkflowFile(t, token, user2.Name, apiRepo.Name, tc.treePath, opts) + + for i := 0; i < len(tc.execPolicies); i++ { + task := runner.fetchTask(t) + jobName := getTaskJobNameByTaskID(t, token, user2.Name, apiRepo.Name, task.Id) + policy := tc.execPolicies[jobName] + assert.NotNil(t, policy) + runner.execTask(t, task, policy) + } + + job2Task := runner.fetchTask(t) + needs := job2Task.Needs + assert.Len(t, needs, len(tc.expectedOutputs)) + for jobID, outputs := range tc.expectedOutputs { + assert.Len(t, needs[jobID].Outputs, len(outputs)) + for outputKey, outputValue := range outputs { + assert.Equal(t, outputValue, needs[jobID].Outputs[outputKey]) + } + } + }) + } + }) +} + +func createActionsTestRepo(t *testing.T, authToken, repoName string, isPrivate bool) *api.Repository { + req := NewRequestWithJSON(t, "POST", "/api/v1/user/repos", &api.CreateRepoOption{ + Name: repoName, + Private: isPrivate, + Readme: "Default", + AutoInit: true, + DefaultBranch: "main", + }).AddTokenAuth(authToken) + resp := MakeRequest(t, req, http.StatusCreated) + var apiRepo api.Repository + DecodeJSON(t, resp, &apiRepo) + return &apiRepo +} + +func getWorkflowCreateFileOptions(u *user_model.User, branch, msg, content string) *api.CreateFileOptions { + return &api.CreateFileOptions{ + FileOptions: api.FileOptions{ + BranchName: branch, + Message: msg, + Author: api.Identity{ + Name: u.Name, + Email: u.Email, + }, + Committer: api.Identity{ + Name: u.Name, + Email: u.Email, + }, + Dates: api.CommitDateOptions{ + Author: time.Now(), + Committer: time.Now(), + }, + }, + ContentBase64: base64.StdEncoding.EncodeToString([]byte(content)), + } +} + +func createWorkflowFile(t *testing.T, authToken, ownerName, repoName, treePath string, opts *api.CreateFileOptions) *api.FileResponse { + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", ownerName, repoName, treePath), opts). + AddTokenAuth(authToken) + resp := MakeRequest(t, req, http.StatusCreated) + var fileResponse api.FileResponse + DecodeJSON(t, resp, &fileResponse) + return &fileResponse +} + +// getTaskJobNameByTaskID get the job name of the task by task ID +// there is currently not an API for querying a task by ID so we have to list all the tasks +func getTaskJobNameByTaskID(t *testing.T, authToken, ownerName, repoName string, taskID int64) string { + // FIXME: we may need to query several pages + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/tasks", ownerName, repoName)). + AddTokenAuth(authToken) + resp := MakeRequest(t, req, http.StatusOK) + var taskRespBefore api.ActionTaskResponse + DecodeJSON(t, resp, &taskRespBefore) + for _, apiTask := range taskRespBefore.Entries { + if apiTask.ID == taskID { + return apiTask.Name + } + } + return "" +} diff --git a/tests/integration/actions_runner_test.go b/tests/integration/actions_runner_test.go new file mode 100644 index 00000000000..7d9ecfeaacf --- /dev/null +++ b/tests/integration/actions_runner_test.go @@ -0,0 +1,163 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "context" + "fmt" + "net/http" + "reflect" + "testing" + "time" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/modules/setting" + + pingv1 "code.gitea.io/actions-proto-go/ping/v1" + "code.gitea.io/actions-proto-go/ping/v1/pingv1connect" + runnerv1 "code.gitea.io/actions-proto-go/runner/v1" + "code.gitea.io/actions-proto-go/runner/v1/runnerv1connect" + "connectrpc.com/connect" + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/types/known/timestamppb" +) + +type mockRunner struct { + client *mockRunnerClient + + id int64 + name string +} + +type mockRunnerClient struct { + pingServiceClient pingv1connect.PingServiceClient + runnerServiceClient runnerv1connect.RunnerServiceClient +} + +func newMockRunner() *mockRunner { + client := newMockRunnerClient("", "") + return &mockRunner{client: client} +} + +func newMockRunnerClient(uuid, token string) *mockRunnerClient { + baseURL := fmt.Sprintf("%sapi/actions", setting.AppURL) + + opt := connect.WithInterceptors(connect.UnaryInterceptorFunc(func(next connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + if uuid != "" { + req.Header().Set("x-runner-uuid", uuid) + } + if token != "" { + req.Header().Set("x-runner-token", token) + } + return next(ctx, req) + } + })) + + client := &mockRunnerClient{ + pingServiceClient: pingv1connect.NewPingServiceClient(http.DefaultClient, baseURL, opt), + runnerServiceClient: runnerv1connect.NewRunnerServiceClient(http.DefaultClient, baseURL, opt), + } + + return client +} + +func (r *mockRunner) doPing(t *testing.T) { + resp, err := r.client.pingServiceClient.Ping(context.Background(), connect.NewRequest(&pingv1.PingRequest{ + Data: "mock-runner", + })) + assert.NoError(t, err) + assert.Equal(t, "Hello, mock-runner!", resp.Msg.Data) +} + +func (r *mockRunner) doRegister(t *testing.T, name, token string, labels []string) { + r.doPing(t) + resp, err := r.client.runnerServiceClient.Register(context.Background(), connect.NewRequest(&runnerv1.RegisterRequest{ + Name: name, + Token: token, + Version: "mock-runner-version", + Labels: labels, + })) + assert.NoError(t, err) + r.id = resp.Msg.Runner.Id + r.name = resp.Msg.Runner.Name + r.client = newMockRunnerClient(resp.Msg.Runner.Uuid, resp.Msg.Runner.Token) +} + +func (r *mockRunner) registerAsRepoRunner(t *testing.T, ownerName, repoName, runnerName string, labels []string) { + session := loginUser(t, ownerName) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/runners/registration-token", ownerName, repoName)).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var registrationToken struct { + Token string `json:"token"` + } + DecodeJSON(t, resp, ®istrationToken) + r.doRegister(t, runnerName, registrationToken.Token, labels) +} + +func (r *mockRunner) fetchTask(t *testing.T, timeout ...time.Duration) *runnerv1.Task { + fetchTimeout := 10 * time.Second + if len(timeout) > 0 { + fetchTimeout = timeout[0] + } + ddl := time.Now().Add(fetchTimeout) + var task *runnerv1.Task + for time.Now().Before(ddl) { + resp, err := r.client.runnerServiceClient.FetchTask(context.Background(), connect.NewRequest(&runnerv1.FetchTaskRequest{ + TasksVersion: 0, + })) + assert.NoError(t, err) + if resp.Msg.Task != nil { + task = resp.Msg.Task + break + } + time.Sleep(time.Second) + } + assert.NotNil(t, task, "failed to fetch a task") + return task +} + +type taskExecPolicy struct { + result runnerv1.Result + outputs map[string]string + logRows []*runnerv1.LogRow + execTime time.Duration +} + +func (r *mockRunner) execTask(t *testing.T, task *runnerv1.Task, policy *taskExecPolicy) { + for idx, lr := range policy.logRows { + resp, err := r.client.runnerServiceClient.UpdateLog(context.Background(), connect.NewRequest(&runnerv1.UpdateLogRequest{ + TaskId: task.Id, + Index: int64(idx), + Rows: []*runnerv1.LogRow{lr}, + NoMore: idx == len(policy.logRows)-1, + })) + assert.NoError(t, err) + assert.EqualValues(t, idx+1, resp.Msg.AckIndex) + } + sentOutputKeys := make([]string, 0, len(policy.outputs)) + for outputKey, outputValue := range policy.outputs { + resp, err := r.client.runnerServiceClient.UpdateTask(context.Background(), connect.NewRequest(&runnerv1.UpdateTaskRequest{ + State: &runnerv1.TaskState{ + Id: task.Id, + Result: runnerv1.Result_RESULT_UNSPECIFIED, + }, + Outputs: map[string]string{outputKey: outputValue}, + })) + assert.NoError(t, err) + sentOutputKeys = append(sentOutputKeys, outputKey) + assert.True(t, reflect.DeepEqual(sentOutputKeys, resp.Msg.SentOutputs)) + } + time.Sleep(policy.execTime) + resp, err := r.client.runnerServiceClient.UpdateTask(context.Background(), connect.NewRequest(&runnerv1.UpdateTaskRequest{ + State: &runnerv1.TaskState{ + Id: task.Id, + Result: policy.result, + StoppedAt: timestamppb.Now(), + }, + })) + assert.NoError(t, err) + assert.Equal(t, policy.result, resp.Msg.State.Result) +}