-
-
Notifications
You must be signed in to change notification settings - Fork 6.2k
Feature: Ephemeral action runners #33570
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 15 commits
f40f079
088ede6
f663240
959957a
6300bb4
4e2f419
546de66
52a96be
971f9b7
84e02ae
abee827
0b17e10
662055b
e50873f
2fe4091
6a9c634
8319c9f
b69a4f1
e21e91d
c87aa86
cbea9db
0823573
1030081
17ce36a
79fa662
9f546ab
b7a3151
8e2085a
905ec6e
6484e95
2d555c8
974c1f2
ff3dddd
01b6f4a
3362bbb
3cad61b
8f79a8f
f4b6e60
e4908f9
41213e9
3f56e44
7d30577
4506057
d66292b
79146cf
21ae2cc
4354c37
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| // Copyright 2024 The Gitea Authors. All rights reserved. | ||
| // SPDX-License-Identifier: MIT | ||
|
|
||
| package v1_24 //nolint | ||
|
|
||
| import ( | ||
| "xorm.io/xorm" | ||
| ) | ||
|
|
||
| func AddEphemeralToActionRunner(x *xorm.Engine) error { | ||
| type ActionRunner struct { | ||
| Ephemeral bool `xorm:"ephemeral"` | ||
| } | ||
|
|
||
| return x.Sync(new(ActionRunner)) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,14 +9,15 @@ import ( | |
| "time" | ||
|
|
||
| actions_model "code.gitea.io/gitea/models/actions" | ||
| "code.gitea.io/gitea/models/db" | ||
| actions_module "code.gitea.io/gitea/modules/actions" | ||
| "code.gitea.io/gitea/modules/log" | ||
| "code.gitea.io/gitea/modules/setting" | ||
| "code.gitea.io/gitea/modules/storage" | ||
| "code.gitea.io/gitea/modules/timeutil" | ||
| ) | ||
|
|
||
| // Cleanup removes expired actions logs, data and artifacts | ||
| // Cleanup removes expired actions logs, data, artifacts and used ephemeral runners | ||
| func Cleanup(ctx context.Context) error { | ||
| // clean up expired artifacts | ||
| if err := CleanupArtifacts(ctx); err != nil { | ||
|
|
@@ -28,6 +29,11 @@ func Cleanup(ctx context.Context) error { | |
| return fmt.Errorf("cleanup logs: %w", err) | ||
| } | ||
|
|
||
| // clean up old ephemeral runners | ||
| if err := CleanupEphemeralRunners(ctx); err != nil { | ||
| return fmt.Errorf("cleanup old ephemeral runners: %w", err) | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
|
|
@@ -123,3 +129,20 @@ func CleanupLogs(ctx context.Context) error { | |
| log.Info("Removed %d logs", count) | ||
| return nil | ||
| } | ||
|
|
||
| const deleteEphemeralRunnerBatchSize = 100 | ||
|
|
||
| // CleanupEphemeralRunners removes used ephemeral runners which are no longer able to process jobs | ||
| func CleanupEphemeralRunners(ctx context.Context) error { | ||
| runners := []*actions_model.ActionRunner{} | ||
| err := db.GetEngine(ctx).Join("INNER", "action_task", "action_task.runner_id = action_runner.id").Where("action_runner.ephemeral and action_task.status != ? and action_task.status != ? and action_task.status != ?)", actions_model.StatusWaiting, actions_model.StatusRunning, actions_model.StatusBlocked).Limit(deleteEphemeralRunnerBatchSize).Find(&runners) | ||
| if err != nil { | ||
| return fmt.Errorf("find runners: %w", err) | ||
| } | ||
| count, err := db.GetEngine(ctx).Delete(&runners) | ||
|
||
| if err != nil { | ||
| return fmt.Errorf("delete runners: %w", err) | ||
| } | ||
| log.Info("Removed %d runners", count) | ||
| return nil | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -24,6 +24,7 @@ import ( | |
| api "code.gitea.io/gitea/modules/structs" | ||
|
|
||
| runnerv1 "code.gitea.io/actions-proto-go/runner/v1" | ||
| "connectrpc.com/connect" | ||
| "github.com/stretchr/testify/assert" | ||
| ) | ||
|
|
||
|
|
@@ -133,7 +134,7 @@ jobs: | |
|
|
||
| apiRepo := createActionsTestRepo(t, token, "actions-jobs-with-needs", false) | ||
| runner := newMockRunner() | ||
| runner.registerAsRepoRunner(t, user2.Name, apiRepo.Name, "mock-runner", []string{"ubuntu-latest"}) | ||
| runner.registerAsRepoRunner(t, user2.Name, apiRepo.Name, "mock-runner", []string{"ubuntu-latest"}, false) | ||
|
|
||
| for _, tc := range testCases { | ||
| t.Run(fmt.Sprintf("test %s", tc.treePath), func(t *testing.T) { | ||
|
|
@@ -319,7 +320,7 @@ jobs: | |
|
|
||
| apiRepo := createActionsTestRepo(t, token, "actions-jobs-outputs-with-matrix", false) | ||
| runner := newMockRunner() | ||
| runner.registerAsRepoRunner(t, user2.Name, apiRepo.Name, "mock-runner", []string{"ubuntu-latest"}) | ||
| runner.registerAsRepoRunner(t, user2.Name, apiRepo.Name, "mock-runner", []string{"ubuntu-latest"}, false) | ||
|
|
||
| for _, tc := range testCases { | ||
| t.Run(fmt.Sprintf("test %s", tc.treePath), func(t *testing.T) { | ||
|
|
@@ -364,7 +365,7 @@ func TestActionsGiteaContext(t *testing.T) { | |
| user2APICtx := NewAPITestContext(t, baseRepo.OwnerName, baseRepo.Name, auth_model.AccessTokenScopeWriteRepository) | ||
|
|
||
| runner := newMockRunner() | ||
| runner.registerAsRepoRunner(t, baseRepo.OwnerName, baseRepo.Name, "mock-runner", []string{"ubuntu-latest"}) | ||
| runner.registerAsRepoRunner(t, baseRepo.OwnerName, baseRepo.Name, "mock-runner", []string{"ubuntu-latest"}, false) | ||
|
|
||
| // init the workflow | ||
| wfTreePath := ".gitea/workflows/pull.yml" | ||
|
|
@@ -438,6 +439,119 @@ jobs: | |
| }) | ||
| } | ||
|
|
||
| // Ephemeral | ||
| func TestActionsGiteaContextEphemeral(t *testing.T) { | ||
| onGiteaRun(t, func(t *testing.T, u *url.URL) { | ||
| user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) | ||
| user2Session := loginUser(t, user2.Name) | ||
| user2Token := getTokenForLoggedInUser(t, user2Session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) | ||
|
|
||
| apiBaseRepo := createActionsTestRepo(t, user2Token, "actions-gitea-context", false) | ||
| baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiBaseRepo.ID}) | ||
| user2APICtx := NewAPITestContext(t, baseRepo.OwnerName, baseRepo.Name, auth_model.AccessTokenScopeWriteRepository) | ||
|
|
||
| runner := newMockRunner() | ||
| runner.registerAsRepoRunner(t, baseRepo.OwnerName, baseRepo.Name, "mock-runner", []string{"ubuntu-latest"}, true) | ||
|
|
||
| // init the workflow | ||
| wfTreePath := ".gitea/workflows/pull.yml" | ||
| wfFileContent := `name: Pull Request | ||
| on: pull_request | ||
| jobs: | ||
| wf1-job: | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - run: echo 'test the pull' | ||
| wf2-job: | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - run: echo 'test the pull' | ||
| ` | ||
| opts := getWorkflowCreateFileOptions(user2, baseRepo.DefaultBranch, fmt.Sprintf("create %s", wfTreePath), wfFileContent) | ||
| createWorkflowFile(t, user2Token, baseRepo.OwnerName, baseRepo.Name, wfTreePath, opts) | ||
| // user2 creates a pull request | ||
| doAPICreateFile(user2APICtx, "user2-patch.txt", &api.CreateFileOptions{ | ||
| FileOptions: api.FileOptions{ | ||
| NewBranchName: "user2/patch-1", | ||
| Message: "create user2-patch.txt", | ||
| Author: api.Identity{ | ||
| Name: user2.Name, | ||
| Email: user2.Email, | ||
| }, | ||
| Committer: api.Identity{ | ||
| Name: user2.Name, | ||
| Email: user2.Email, | ||
| }, | ||
| Dates: api.CommitDateOptions{ | ||
| Author: time.Now(), | ||
| Committer: time.Now(), | ||
| }, | ||
| }, | ||
| ContentBase64: base64.StdEncoding.EncodeToString([]byte("user2-fix")), | ||
| })(t) | ||
| apiPull, err := doAPICreatePullRequest(user2APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, "user2/patch-1")(t) | ||
| assert.NoError(t, err) | ||
| task := runner.fetchTask(t) | ||
| gtCtx := task.Context.GetFields() | ||
| actionTask := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: task.Id}) | ||
| actionRunJob := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: actionTask.JobID}) | ||
| actionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: actionRunJob.RunID}) | ||
| assert.NoError(t, actionRun.LoadAttributes(context.Background())) | ||
|
|
||
| assert.Equal(t, user2.Name, gtCtx["actor"].GetStringValue()) | ||
| assert.Equal(t, setting.AppURL+"api/v1", gtCtx["api_url"].GetStringValue()) | ||
| assert.Equal(t, apiPull.Base.Ref, gtCtx["base_ref"].GetStringValue()) | ||
| runEvent := map[string]any{} | ||
| assert.NoError(t, json.Unmarshal([]byte(actionRun.EventPayload), &runEvent)) | ||
| assert.True(t, reflect.DeepEqual(gtCtx["event"].GetStructValue().AsMap(), runEvent)) | ||
| assert.Equal(t, actionRun.TriggerEvent, gtCtx["event_name"].GetStringValue()) | ||
| assert.Equal(t, apiPull.Head.Ref, gtCtx["head_ref"].GetStringValue()) | ||
| assert.Equal(t, actionRunJob.JobID, gtCtx["job"].GetStringValue()) | ||
| assert.Equal(t, actionRun.Ref, gtCtx["ref"].GetStringValue()) | ||
| assert.Equal(t, (git.RefName(actionRun.Ref)).ShortName(), gtCtx["ref_name"].GetStringValue()) | ||
| assert.False(t, gtCtx["ref_protected"].GetBoolValue()) | ||
| assert.Equal(t, string((git.RefName(actionRun.Ref)).RefType()), gtCtx["ref_type"].GetStringValue()) | ||
| assert.Equal(t, actionRun.Repo.OwnerName+"/"+actionRun.Repo.Name, gtCtx["repository"].GetStringValue()) | ||
| assert.Equal(t, actionRun.Repo.OwnerName, gtCtx["repository_owner"].GetStringValue()) | ||
| assert.Equal(t, actionRun.Repo.HTMLURL(), gtCtx["repositoryUrl"].GetStringValue()) | ||
| assert.Equal(t, fmt.Sprint(actionRunJob.RunID), gtCtx["run_id"].GetStringValue()) | ||
| assert.Equal(t, fmt.Sprint(actionRun.Index), gtCtx["run_number"].GetStringValue()) | ||
| assert.Equal(t, fmt.Sprint(actionRunJob.Attempt), gtCtx["run_attempt"].GetStringValue()) | ||
| assert.Equal(t, "Actions", gtCtx["secret_source"].GetStringValue()) | ||
| assert.Equal(t, setting.AppURL, gtCtx["server_url"].GetStringValue()) | ||
| assert.Equal(t, actionRun.CommitSHA, gtCtx["sha"].GetStringValue()) | ||
| assert.Equal(t, actionRun.WorkflowID, gtCtx["workflow"].GetStringValue()) | ||
| assert.Equal(t, setting.Actions.DefaultActionsURL.URL(), gtCtx["gitea_default_actions_url"].GetStringValue()) | ||
| token := gtCtx["token"].GetStringValue() | ||
| assert.Equal(t, actionTask.TokenLastEight, token[len(token)-8:]) | ||
|
|
||
| resp, err := runner.client.runnerServiceClient.FetchTask(context.Background(), connect.NewRequest(&runnerv1.FetchTaskRequest{ | ||
| TasksVersion: 0, | ||
| })) | ||
| assert.NoError(t, err) | ||
| assert.Nil(t, resp.Msg.Task) | ||
| runner.client.runnerServiceClient.UpdateTask(context.Background(), connect.NewRequest(&runnerv1.UpdateTaskRequest{ | ||
| State: &runnerv1.TaskState{ | ||
| Id: actionTask.ID, | ||
| Result: runnerv1.Result_RESULT_SUCCESS, | ||
| }, | ||
| })) | ||
| resp, err = runner.client.runnerServiceClient.FetchTask(context.Background(), connect.NewRequest(&runnerv1.FetchTaskRequest{ | ||
| TasksVersion: 0, | ||
| })) | ||
| assert.Error(t, err) | ||
| assert.Nil(t, resp) | ||
|
|
||
| resp, err = runner.client.runnerServiceClient.FetchTask(context.Background(), connect.NewRequest(&runnerv1.FetchTaskRequest{ | ||
| TasksVersion: 0, | ||
| })) | ||
| assert.Error(t, err) | ||
| assert.Nil(t, resp) | ||
|
|
||
| doAPIDeleteRepository(user2APICtx)(t) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No need to delete after test. Every test will reset the env, so skipping "doAPIDeleteRepository" could save some time.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I guess I am going to create a PR that removes the other "doAPIDeleteRepository" as well, just copy pasted this part.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I had to revert this suggestion @wxiaoguang removing this breaks pgsql in CI, so "Every test will reset the env" does not work for Gitea Action Tests right now for all databases. See here #33570 (comment)
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in |
||
| }) | ||
| } | ||
|
|
||
| func createActionsTestRepo(t *testing.T, authToken, repoName string, isPrivate bool) *api.Repository { | ||
| req := NewRequestWithJSON(t, "POST", "/api/v1/user/repos", &api.CreateRepoOption{ | ||
| Name: repoName, | ||
|
|
||

There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I also think about to remove offline EphemeralRunners here that didn't connect within 24h as auto cleanup.
We currently cannot remove runners via api.