-
-
Notifications
You must be signed in to change notification settings - Fork 5.8k
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 all 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 2025 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 NOT NULL DEFAULT false"` | ||
} | ||
|
||
return x.Sync(new(ActionRunner)) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -21,8 +21,10 @@ import ( | |
"code.gitea.io/gitea/modules/json" | ||
"code.gitea.io/gitea/modules/setting" | ||
api "code.gitea.io/gitea/modules/structs" | ||
actions_service "code.gitea.io/gitea/services/actions" | ||
|
||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1" | ||
"connectrpc.com/connect" | ||
"github.com/stretchr/testify/assert" | ||
) | ||
|
||
|
@@ -132,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) { | ||
|
@@ -318,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) { | ||
|
@@ -363,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" | ||
|
@@ -437,6 +439,156 @@ 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) | ||
|
||
// verify CleanupEphemeralRunners does not remove this runner | ||
err := actions_service.CleanupEphemeralRunners(t.Context()) | ||
assert.NoError(t, err) | ||
|
||
// 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(t.Context())) | ||
|
||
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:]) | ||
|
||
// verify CleanupEphemeralRunners does not remove this runner | ||
err = actions_service.CleanupEphemeralRunners(t.Context()) | ||
assert.NoError(t, err) | ||
|
||
resp, err := runner.client.runnerServiceClient.FetchTask(t.Context(), connect.NewRequest(&runnerv1.FetchTaskRequest{ | ||
TasksVersion: 0, | ||
})) | ||
assert.NoError(t, err) | ||
assert.Nil(t, resp.Msg.Task) | ||
|
||
// verify CleanupEphemeralRunners does not remove this runner | ||
err = actions_service.CleanupEphemeralRunners(t.Context()) | ||
assert.NoError(t, err) | ||
|
||
runner.client.runnerServiceClient.UpdateTask(t.Context(), connect.NewRequest(&runnerv1.UpdateTaskRequest{ | ||
State: &runnerv1.TaskState{ | ||
Id: actionTask.ID, | ||
Result: runnerv1.Result_RESULT_SUCCESS, | ||
}, | ||
})) | ||
resp, err = runner.client.runnerServiceClient.FetchTask(t.Context(), connect.NewRequest(&runnerv1.FetchTaskRequest{ | ||
TasksVersion: 0, | ||
})) | ||
assert.Error(t, err) | ||
assert.Nil(t, resp) | ||
|
||
resp, err = runner.client.runnerServiceClient.FetchTask(t.Context(), connect.NewRequest(&runnerv1.FetchTaskRequest{ | ||
TasksVersion: 0, | ||
})) | ||
assert.Error(t, err) | ||
assert.Nil(t, resp) | ||
|
||
// create an runner that picks a job and get force cancelled | ||
runnerToBeRemoved := newMockRunner() | ||
runnerToBeRemoved.registerAsRepoRunner(t, baseRepo.OwnerName, baseRepo.Name, "mock-runner-to-be-removed", []string{"ubuntu-latest"}, true) | ||
|
||
taskToStopAPIObj := runnerToBeRemoved.fetchTask(t) | ||
|
||
taskToStop := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: taskToStopAPIObj.Id}) | ||
|
||
// verify CleanupEphemeralRunners does not remove the custom crafted runner | ||
err = actions_service.CleanupEphemeralRunners(t.Context()) | ||
assert.NoError(t, err) | ||
|
||
runnerToRemove := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: taskToStop.RunnerID}) | ||
|
||
err = actions_model.StopTask(t.Context(), taskToStop.ID, actions_model.StatusFailure) | ||
assert.NoError(t, err) | ||
|
||
// verify CleanupEphemeralRunners does remove the custom crafted runner | ||
err = actions_service.CleanupEphemeralRunners(t.Context()) | ||
assert.NoError(t, err) | ||
|
||
unittest.AssertNotExistsBean(t, &actions_model.ActionRunner{ID: runnerToRemove.ID}) | ||
|
||
// this cleanup is required to allow further tests to pass | ||
doAPIDeleteRepository(user2APICtx)(t) | ||
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. 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. 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) 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. 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.