feat: implement ephemeral runners (#9962)

As described in [this comment](https://gitea.com/gitea/act_runner/issues/19#issuecomment-739221) one-job runners are not secure when running in host mode. We implemented a routine preventing runner tokens from receiving a second job in order to render a potentially compromised token useless. Also we implemented a routine that removes finished runners as soon as possible.

Big thanks to [ChristopherHX](https://github.com/ChristopherHX) who did all the work for gitea!

Rel: #9407

## Checklist

The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org).

### Tests

- I added test coverage for Go changes...
  - [ ] in their respective `*_test.go` for unit tests.
  - [x] in the `tests/integration` directory if it involves interactions with a live Forgejo server.
- I added test coverage for JavaScript changes...
  - [ ] in `web_src/js/*.test.js` if it can be unit tested.
  - [ ] in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/README.md#end-to-end-tests)).

### Documentation

- [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change.
- [ ] I did not document these changes and I do not expect someone else to do it.

### Release notes

- [ ] I do not want this change to show in the release notes.
- [ ] I want the title to show in the release notes with a link to this pull request.
- [ ] I want the content of the `release-notes/<pull request number>.md` to be be used for the release notes instead of the title.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/9962
Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.codeberg.org>
Reviewed-by: Mathieu Fenniak <mfenniak@noreply.codeberg.org>
Co-authored-by: Manuel Ganter <manuel.ganter@think-ahead.tech>
Co-committed-by: Manuel Ganter <manuel.ganter@think-ahead.tech>
This commit is contained in:
Manuel Ganter 2026-02-16 18:56:56 +01:00 committed by Mathieu Fenniak
parent b085be779c
commit 5b6bbabd74
33 changed files with 1130 additions and 41 deletions

View file

@ -102,6 +102,11 @@ func SubcmdActionsRegister(ctx context.Context) *cli.Command {
Value: "",
Usage: "version of the runner (not required since v1.21)",
},
&cli.BoolFlag{
Name: "ephemeral",
Value: false,
Usage: "instruct Forgejo to permanently unregister this runner after it has run one job",
},
},
}
}
@ -172,6 +177,7 @@ func RunRegister(ctx context.Context, cli *cli.Command) error {
scope := cli.String("scope")
name := cli.String("name")
version := cli.String("version")
ephemeral := cli.Bool("ephemeral")
labels, err := getLabels(cli)
if err != nil {
return err
@ -199,7 +205,7 @@ func RunRegister(ctx context.Context, cli *cli.Command) error {
return err
}
runner, err := actions_model.RegisterRunner(ctx, owner, repo, secret, labels, name, version)
runner, err := actions_model.RegisterRunner(ctx, owner, repo, secret, labels, name, version, ephemeral)
if err != nil {
return fmt.Errorf("error while registering runner: %v", err)
}

View file

@ -14,7 +14,7 @@ import (
gouuid "github.com/google/uuid"
)
func RegisterRunner(ctx context.Context, ownerID, repoID int64, token string, labels *[]string, name, version string) (*ActionRunner, error) {
func RegisterRunner(ctx context.Context, ownerID, repoID int64, token string, labels *[]string, name, version string, ephemeral bool) (*ActionRunner, error) {
uuid, err := gouuid.FromBytes([]byte(token[:16]))
if err != nil {
return nil, fmt.Errorf("gouuid.FromBytes %v", err)
@ -60,11 +60,12 @@ func RegisterRunner(ctx context.Context, ownerID, repoID int64, token string, la
//
name, _ = util.SplitStringAtByteN(name, 255)
cols := []string{"name", "owner_id", "repo_id", "version"}
cols := []string{"name", "owner_id", "repo_id", "version", "ephemeral"}
runner.Name = name
runner.OwnerID = ownerID
runner.RepoID = repoID
runner.Version = version
runner.Ephemeral = ephemeral
if labels != nil {
runner.AgentLabels = *labels
cols = append(cols, "agent_labels")

View file

@ -22,9 +22,11 @@ func TestActions_RegisterRunner_Token(t *testing.T) {
labels := []string{}
name := "runner"
version := "v1.2.3"
runner, err := RegisterRunner(db.DefaultContext, ownerID, repoID, token, &labels, name, version)
ephemeral := true
runner, err := RegisterRunner(db.DefaultContext, ownerID, repoID, token, &labels, name, version, ephemeral)
require.NoError(t, err)
assert.Equal(t, name, runner.Name)
assert.True(t, runner.Ephemeral)
assert.Equal(t, 1, subtle.ConstantTimeCompare([]byte(runner.TokenHash), []byte(auth_model.HashToken(token, runner.TokenSalt))), "the token cannot be verified with the same method as routers/api/actions/runner/interceptor.go as of 8228751c55d6a4263f0fec2932ca16181c09c97d")
}
@ -44,7 +46,7 @@ func TestActions_RegisterRunner_TokenUpdate(t *testing.T) {
"the initial token should match the runner's secret",
)
RegisterRunner(db.DefaultContext, before.OwnerID, before.RepoID, newToken, nil, before.Name, before.Version)
RegisterRunner(db.DefaultContext, before.OwnerID, before.RepoID, newToken, nil, before.Name, before.Version, false)
after := unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: recordID})
@ -66,10 +68,11 @@ func TestActions_RegisterRunner_CreateWithLabels(t *testing.T) {
token := "0123456789012345678901234567890123456789"
name := "runner"
version := "v1.2.3"
ephemeral := true
labels := []string{"woop", "doop"}
labelsCopy := labels // labels may be affected by the tested function so we copy them
runner, err := RegisterRunner(db.DefaultContext, ownerID, repoID, token, &labels, name, version)
runner, err := RegisterRunner(db.DefaultContext, ownerID, repoID, token, &labels, name, version, ephemeral)
require.NoError(t, err)
// Check that the returned record has been updated, except for the labels
@ -78,6 +81,7 @@ func TestActions_RegisterRunner_CreateWithLabels(t *testing.T) {
assert.Equal(t, name, runner.Name)
assert.Equal(t, version, runner.Version)
assert.Equal(t, labelsCopy, runner.AgentLabels)
assert.Equal(t, ephemeral, runner.Ephemeral)
// Check that whatever is in the DB has been updated, except for the labels
after := unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: runner.ID})
@ -86,6 +90,7 @@ func TestActions_RegisterRunner_CreateWithLabels(t *testing.T) {
assert.Equal(t, name, after.Name)
assert.Equal(t, version, after.Version)
assert.Equal(t, labelsCopy, after.AgentLabels)
assert.Equal(t, ephemeral, after.Ephemeral)
}
func TestActions_RegisterRunner_CreateWithoutLabels(t *testing.T) {
@ -95,8 +100,9 @@ func TestActions_RegisterRunner_CreateWithoutLabels(t *testing.T) {
token := "0123456789012345678901234567890123456789"
name := "runner"
version := "v1.2.3"
ephemeral := true
runner, err := RegisterRunner(db.DefaultContext, ownerID, repoID, token, nil, name, version)
runner, err := RegisterRunner(db.DefaultContext, ownerID, repoID, token, nil, name, version, ephemeral)
require.NoError(t, err)
// Check that the returned record has been updated, except for the labels
@ -105,6 +111,7 @@ func TestActions_RegisterRunner_CreateWithoutLabels(t *testing.T) {
assert.Equal(t, name, runner.Name)
assert.Equal(t, version, runner.Version)
assert.Equal(t, []string{}, runner.AgentLabels)
assert.Equal(t, ephemeral, runner.Ephemeral)
// Check that whatever is in the DB has been updated, except for the labels
after := unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: runner.ID})
@ -113,6 +120,7 @@ func TestActions_RegisterRunner_CreateWithoutLabels(t *testing.T) {
assert.Equal(t, name, after.Name)
assert.Equal(t, version, after.Version)
assert.Equal(t, []string{}, after.AgentLabels)
assert.Equal(t, ephemeral, after.Ephemeral)
}
func TestActions_RegisterRunner_UpdateWithLabels(t *testing.T) {
@ -125,10 +133,11 @@ func TestActions_RegisterRunner_UpdateWithLabels(t *testing.T) {
newRepoID := int64(1)
newName := "rennur"
newVersion := "v4.5.6"
ephemeral := true
newLabels := []string{"warp", "darp"}
labelsCopy := newLabels // labels may be affected by the tested function so we copy them
runner, err := RegisterRunner(db.DefaultContext, newOwnerID, newRepoID, token, &newLabels, newName, newVersion)
runner, err := RegisterRunner(db.DefaultContext, newOwnerID, newRepoID, token, &newLabels, newName, newVersion, ephemeral)
require.NoError(t, err)
// Check that the returned record has been updated
@ -137,6 +146,7 @@ func TestActions_RegisterRunner_UpdateWithLabels(t *testing.T) {
assert.Equal(t, newName, runner.Name)
assert.Equal(t, newVersion, runner.Version)
assert.Equal(t, labelsCopy, runner.AgentLabels)
assert.Equal(t, ephemeral, runner.Ephemeral)
// Check that whatever is in the DB has been updated
after := unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: recordID})
@ -145,6 +155,7 @@ func TestActions_RegisterRunner_UpdateWithLabels(t *testing.T) {
assert.Equal(t, newName, after.Name)
assert.Equal(t, newVersion, after.Version)
assert.Equal(t, labelsCopy, after.AgentLabels)
assert.Equal(t, ephemeral, after.Ephemeral)
}
func TestActions_RegisterRunner_UpdateWithoutLabels(t *testing.T) {
@ -157,8 +168,9 @@ func TestActions_RegisterRunner_UpdateWithoutLabels(t *testing.T) {
newRepoID := int64(1)
newName := "rennur"
newVersion := "v4.5.6"
ephemeral := true
runner, err := RegisterRunner(db.DefaultContext, newOwnerID, newRepoID, token, nil, newName, newVersion)
runner, err := RegisterRunner(db.DefaultContext, newOwnerID, newRepoID, token, nil, newName, newVersion, ephemeral)
require.NoError(t, err)
// Check that the returned record has been updated, except for the labels
@ -167,6 +179,7 @@ func TestActions_RegisterRunner_UpdateWithoutLabels(t *testing.T) {
assert.Equal(t, newName, runner.Name)
assert.Equal(t, newVersion, runner.Version)
assert.Equal(t, before.AgentLabels, runner.AgentLabels)
assert.Equal(t, ephemeral, runner.Ephemeral)
// Check that whatever is in the DB has been updated, except for the labels
after := unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: recordID})
@ -175,4 +188,5 @@ func TestActions_RegisterRunner_UpdateWithoutLabels(t *testing.T) {
assert.Equal(t, newName, after.Name)
assert.Equal(t, newVersion, after.Version)
assert.Equal(t, before.AgentLabels, after.AgentLabels)
assert.Equal(t, ephemeral, after.Ephemeral)
}

View file

@ -61,6 +61,8 @@ type ActionRunner struct {
// Store labels defined in state file (default: .runner file) of `act_runner`
AgentLabels []string `xorm:"TEXT"`
// Store if this is a runner that only ever get one single job assigned
Ephemeral bool `xorm:"ephemeral NOT NULL DEFAULT false"`
Created timeutil.TimeStamp `xorm:"created"`
Updated timeutil.TimeStamp `xorm:"updated"`

View file

@ -174,6 +174,10 @@ func GetTaskByID(ctx context.Context, id int64) (*ActionTask, error) {
return &task, nil
}
func HasTaskForRunner(ctx context.Context, runnerID int64) (bool, error) {
return db.GetEngine(ctx).Where("runner_id = ?", runnerID).Exist(&ActionTask{})
}
func GetTaskByJobAttempt(ctx context.Context, jobID, attempt int64) (*ActionTask, error) {
var task ActionTask
has, err := db.GetEngine(ctx).Where("job_id=?", jobID).Where("attempt=?", attempt).Get(&task)

View file

@ -0,0 +1,24 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package forgejo_migrations
import (
"xorm.io/xorm"
)
func init() {
registerMigration(&Migration{
Description: "add ephemeral to action_runner",
Upgrade: addRunnerEphemeralField,
})
}
func addRunnerEphemeralField(x *xorm.Engine) error {
type ActionRunner struct {
Ephemeral bool `xorm:"ephemeral NOT NULL DEFAULT false"`
}
_, err := x.SyncWithOptions(xorm.SyncOptions{IgnoreDropIndices: true}, new(ActionRunner))
return err
}

View file

@ -80,4 +80,6 @@ type ActionRunner struct {
Labels []string `json:"labels"`
// Description provides optional details about this runner.
Description string `json:"description"`
// Indicates if runner is ephemeral runner
Ephemeral bool `json:"ephemeral"`
}

View file

@ -15,12 +15,18 @@ type RegisterRunnerOptions struct {
//
// required: false
Description string `json:"description"`
// Register as ephemeral runner https://forgejo.org/docs/latest/admin/actions/security/#ephemeral-runner
//
// required: false
Ephemeral bool `json:"ephemeral"`
}
// RegisterRunnerResponse contains the details of the just registered runner.
// swagger:model
type RegisterRunnerResponse struct {
ID int64 `json:"id" binding:"Required"`
UUID string `json:"uuid" binding:"Required"`
Token string `json:"token" binding:"Required"`
ID int64 `json:"id" binding:"Required"`
UUID string `json:"uuid" binding:"Required"`
Token string `json:"token" binding:"Required"`
Ephemeral bool `json:"ephemeral" binding:"Required"`
}

View file

@ -79,6 +79,7 @@ func (s *Service) Register(
RepoID: runnerToken.RepoID,
Version: req.Msg.Version,
AgentLabels: labels,
Ephemeral: req.Msg.Ephemeral,
}
runner.GenerateToken()
@ -95,12 +96,13 @@ func (s *Service) Register(
res := connect.NewResponse(&runnerv1.RegisterResponse{
Runner: &runnerv1.Runner{
Id: runner.ID,
Uuid: runner.UUID,
Token: runner.Token,
Name: runner.Name,
Version: runner.Version,
Labels: runner.AgentLabels,
Id: runner.ID,
Uuid: runner.UUID,
Token: runner.Token,
Name: runner.Name,
Version: runner.Version,
Labels: runner.AgentLabels,
Ephemeral: runner.Ephemeral,
},
})
@ -120,12 +122,13 @@ func (s *Service) Declare(
return connect.NewResponse(&runnerv1.DeclareResponse{
Runner: &runnerv1.Runner{
Id: runner.ID,
Uuid: runner.UUID,
Token: runner.Token,
Name: runner.Name,
Version: runner.Version,
Labels: runner.AgentLabels,
Id: runner.ID,
Uuid: runner.UUID,
Token: runner.Token,
Name: runner.Name,
Version: runner.Version,
Labels: runner.AgentLabels,
Ephemeral: runner.Ephemeral,
},
}), nil
}
@ -251,6 +254,14 @@ func (s *Service) UpdateTask(
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("fail to increase task version: %w", err))
}
}
if runner.Ephemeral {
err := actions_model.DeleteRunner(ctx, runner)
if err != nil {
log.Error("failed to delete ephemeral runner %v, %w", task.RunnerID, err)
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to delete ephemeral runner %v, %w", task.RunnerID, err))
}
}
}
return connect.NewResponse(&runnerv1.UpdateTaskResponse{

View file

@ -162,6 +162,7 @@ func RegisterRunner(ctx *context.APIContext, ownerID, repoID int64) {
OwnerID: ownerID,
RepoID: repoID,
Description: options.Description,
Ephemeral: options.Ephemeral,
}
runner.GenerateToken()
if err := actions_model.CreateRunner(ctx, runner); err != nil {
@ -169,9 +170,10 @@ func RegisterRunner(ctx *context.APIContext, ownerID, repoID int64) {
}
response := &structs.RegisterRunnerResponse{
ID: runner.ID,
UUID: runner.UUID,
Token: runner.Token,
ID: runner.ID,
UUID: runner.UUID,
Token: runner.Token,
Ephemeral: runner.Ephemeral,
}
ctx.JSON(http.StatusCreated, response)
}

View file

@ -11,23 +11,31 @@ import (
"time"
actions_model "forgejo.org/models/actions"
"forgejo.org/models/db"
actions_module "forgejo.org/modules/actions"
"forgejo.org/modules/log"
"forgejo.org/modules/setting"
"forgejo.org/modules/storage"
"forgejo.org/modules/timeutil"
"xorm.io/builder"
)
// 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 {
return fmt.Errorf("cleanup artifacts: %w", err)
return fmt.Errorf("failed to clean up artifacts: %w", err)
}
// clean up old logs
if err := CleanupLogs(ctx); err != nil {
return fmt.Errorf("cleanup logs: %w", err)
return fmt.Errorf("failed to clean up logs: %w", err)
}
// clean up old ephemeral runners
if err := CleanupEphemeralRunners(ctx); err != nil {
return fmt.Errorf("failed to clean up old ephemeral runners: %w", err)
}
return nil
@ -127,6 +135,50 @@ func CleanupLogs(ctx context.Context) error {
return nil
}
// CleanupEphemeralRunners removes used ephemeral runners which are no longer able to process jobs
func CleanupEphemeralRunners(ctx context.Context) error {
var ids []int
err := db.GetEngine(ctx).
Table("`action_runner`").
Select("DISTINCT `action_runner`.id").
Join("INNER", "`action_task`", "`action_task`.`runner_id` = `action_runner`.`id`").
Where(builder.Eq{"`action_runner`.`ephemeral`": true}).
And(builder.In("`action_task`.`status`", actions_model.DoneStatuses())).
Find(&ids)
if err != nil {
return fmt.Errorf("failed to find ephemeral runners: %w", err)
}
res, err := db.GetEngine(ctx).
In("id", ids).
Delete(&actions_model.ActionRunner{})
if err != nil {
return fmt.Errorf("failed to delete ephemeral runners: %w", err)
}
log.Info("Removed %d ephemeral runners", res)
return nil
}
// CleanupEphemeralRunnersByPickedTaskOfRepo removes all ephemeral runners that have active/finished tasks on the given repository
func CleanupEphemeralRunnersByPickedTaskOfRepo(ctx context.Context, repoID int64) error {
subQuery := builder.Select("`action_runner`.id").
From(builder.Select("*").From("`action_runner`"), "`action_runner`"). // mysql needs this redundant subquery
Join("INNER", "`action_task`", "`action_task`.`runner_id` = `action_runner`.`id`").
Where(builder.And(builder.Eq{"`action_runner`.`ephemeral`": true}, builder.Eq{"`action_task`.`repo_id`": repoID}))
b := builder.Delete(builder.In("id", subQuery)).From("`action_runner`")
res, err := db.GetEngine(ctx).Exec(b)
if err != nil {
return fmt.Errorf("find runners: %w", err)
}
affected, err := res.RowsAffected()
if err != nil {
return err
}
log.Info("Removed %d runners", affected)
return nil
}
// CleanupOfflineRunners removes offline runners
func CleanupOfflineRunners(ctx context.Context, duration time.Duration, globalOnly bool) error {
olderThan := timeutil.TimeStampNow().AddDuration(-duration)

View file

@ -29,3 +29,78 @@ func TestCleanup(t *testing.T) {
assert.Nil(t, task.LogIndexes)
})
}
func TestCleanupEphemeralRunners(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
t.Run("Deletes ephemeral runner with successful task", func(t *testing.T) {
unittest.AssertSuccessfulInsert(t, &actions_model.ActionRunner{ID: 2001, UUID: "2001-uuid", TokenHash: "2001-hash", Ephemeral: true})
unittest.AssertSuccessfulInsert(t, &actions_model.ActionTask{ID: 2001, RunnerID: 2001, TokenHash: "task-2001-hash", Status: actions_model.StatusSuccess})
require.NoError(t, CleanupEphemeralRunners(db.DefaultContext))
unittest.AssertNotExistsBean(t, &actions_model.ActionRunner{ID: 2001})
})
t.Run("Deletes ephemeral runner with failed task", func(t *testing.T) {
unittest.AssertSuccessfulInsert(t, &actions_model.ActionRunner{ID: 2002, UUID: "2002-uuid", TokenHash: "2002-hash", Ephemeral: true})
unittest.AssertSuccessfulInsert(t, &actions_model.ActionTask{ID: 2002, RunnerID: 2002, TokenHash: "task-2002-hash", Status: actions_model.StatusFailure})
require.NoError(t, CleanupEphemeralRunners(db.DefaultContext))
unittest.AssertNotExistsBean(t, &actions_model.ActionRunner{ID: 2002})
})
t.Run("Deletes ephemeral runner with cancelled task", func(t *testing.T) {
unittest.AssertSuccessfulInsert(t, &actions_model.ActionRunner{ID: 2003, UUID: "2003-uuid", TokenHash: "2003-hash", Ephemeral: true})
unittest.AssertSuccessfulInsert(t, &actions_model.ActionTask{ID: 2003, RunnerID: 2003, TokenHash: "task-2003-hash", Status: actions_model.StatusCancelled})
require.NoError(t, CleanupEphemeralRunners(db.DefaultContext))
unittest.AssertNotExistsBean(t, &actions_model.ActionRunner{ID: 2003})
})
t.Run("Deletes ephemeral runner with skipped task", func(t *testing.T) {
unittest.AssertSuccessfulInsert(t, &actions_model.ActionRunner{ID: 2004, UUID: "2004-uuid", TokenHash: "2004-hash", Ephemeral: true})
unittest.AssertSuccessfulInsert(t, &actions_model.ActionTask{ID: 2004, RunnerID: 2004, TokenHash: "task-2004-hash", Status: actions_model.StatusSkipped})
require.NoError(t, CleanupEphemeralRunners(db.DefaultContext))
unittest.AssertNotExistsBean(t, &actions_model.ActionRunner{ID: 2004})
})
t.Run("Keeps ephemeral runner with running task", func(t *testing.T) {
unittest.AssertSuccessfulInsert(t, &actions_model.ActionRunner{ID: 2005, UUID: "2005-uuid", TokenHash: "2005-hash", Ephemeral: true})
unittest.AssertSuccessfulInsert(t, &actions_model.ActionTask{ID: 2005, RunnerID: 2005, TokenHash: "task-2005-hash", Status: actions_model.StatusRunning})
require.NoError(t, CleanupEphemeralRunners(db.DefaultContext))
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: 2005})
})
t.Run("Keeps ephemeral runner with waiting task", func(t *testing.T) {
unittest.AssertSuccessfulInsert(t, &actions_model.ActionRunner{ID: 2006, UUID: "2006-uuid", TokenHash: "2006-hash", Ephemeral: true})
unittest.AssertSuccessfulInsert(t, &actions_model.ActionTask{ID: 2006, RunnerID: 2006, TokenHash: "task-2006-hash", Status: actions_model.StatusWaiting})
require.NoError(t, CleanupEphemeralRunners(db.DefaultContext))
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: 2006})
})
t.Run("Keeps ephemeral runner with no tasks", func(t *testing.T) {
unittest.AssertSuccessfulInsert(t, &actions_model.ActionRunner{ID: 2007, UUID: "2007-uuid", TokenHash: "2007-hash", Ephemeral: true})
require.NoError(t, CleanupEphemeralRunners(db.DefaultContext))
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: 2007})
})
t.Run("Keeps non-ephemeral runner with completed task", func(t *testing.T) {
unittest.AssertSuccessfulInsert(t, &actions_model.ActionRunner{ID: 2008, UUID: "2008-uuid", TokenHash: "2008-hash", Ephemeral: false})
unittest.AssertSuccessfulInsert(t, &actions_model.ActionTask{ID: 2008, RunnerID: 2008, TokenHash: "task-2008-hash", Status: actions_model.StatusSuccess})
require.NoError(t, CleanupEphemeralRunners(db.DefaultContext))
unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: 2008})
})
}

View file

@ -26,6 +26,19 @@ func PickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv
job *actions_model.ActionRunJob
)
if runner.Ephemeral {
hasRunnerAssignedTask, err := actions_model.HasTaskForRunner(ctx, runner.ID)
// Let the runner retry the request, do not allow to proceed
if err != nil {
return nil, false, err
}
// if runner has task, dont assign new task
if hasRunnerAssignedTask {
return nil, false, nil
}
}
if err := db.WithTx(ctx, func(ctx context.Context) error {
t, ok, err := actions_model.CreateTaskForRunner(ctx, runner)
if err != nil {
@ -166,6 +179,18 @@ func StopTask(ctx context.Context, taskID int64, status actions_model.Status) er
return err
}
runner := &actions_model.ActionRunner{}
if _, err := e.ID(task.RunnerID).Get(runner); err != nil {
return fmt.Errorf("failed to find runner assigned to task")
}
if runner.Ephemeral {
err := actions_model.DeleteRunner(ctx, runner)
if err != nil {
return fmt.Errorf("failed to remove ephemeral runner from stopped task: %w", err)
}
}
if err := task.LoadAttributes(ctx); err != nil {
return err
}

View file

@ -547,6 +547,7 @@ func ToActionRunner(runner *actions_model.ActionRunner) (api.ActionRunner, error
Version: runner.Version,
Status: status.String(),
Labels: runner.AgentLabels,
Ephemeral: runner.Ephemeral,
}
return actionRunner, nil

View file

@ -30,6 +30,7 @@ import (
"forgejo.org/modules/log"
"forgejo.org/modules/setting"
"forgejo.org/modules/storage"
actions_service "forgejo.org/services/actions"
federation_service "forgejo.org/services/federation"
"xorm.io/builder"
@ -145,6 +146,13 @@ func DeleteRepositoryDirectly(ctx context.Context, doer *user_model.User, repoID
}
}
// CleanupEphemeralRunnersByPickedTaskOfRepo deletes ephemeral global/org/user that have started any task of this repo, as they cannot pick a second task
// This method will delete affected ephemeral global/org/user runners
// &actions_model.ActionRunner{RepoID: repoID} does only handle ephemeral repository runners
if err := actions_service.CleanupEphemeralRunnersByPickedTaskOfRepo(ctx, repoID); err != nil {
return fmt.Errorf("cleanupEphemeralRunners: %w", err)
}
if err := db.DeleteBeans(ctx,
&access_model.Access{RepoID: repo.ID},
&activities_model.Action{RepoID: repo.ID},

View file

@ -22371,6 +22371,11 @@
"type": "string",
"x-go-name": "Description"
},
"ephemeral": {
"description": "Indicates if runner is ephemeral runner",
"type": "boolean",
"x-go-name": "Ephemeral"
},
"id": {
"description": "ID uniquely identifies this runner.",
"type": "integer",
@ -28516,6 +28521,11 @@
"type": "string",
"x-go-name": "Description"
},
"ephemeral": {
"description": "Register as ephemeral runner https://forgejo.org/docs/latest/admin/actions/security/#ephemeral-runner",
"type": "boolean",
"x-go-name": "Ephemeral"
},
"name": {
"description": "Name of the runner to register. The name of the runner does not have to be unique.",
"type": "string",
@ -28528,6 +28538,10 @@
"type": "object",
"title": "RegisterRunnerResponse contains the details of the just registered runner.",
"properties": {
"ephemeral": {
"type": "boolean",
"x-go-name": "Ephemeral"
},
"id": {
"type": "integer",
"format": "int64",

View file

@ -22,8 +22,10 @@ import (
"forgejo.org/modules/json"
"forgejo.org/modules/setting"
api "forgejo.org/modules/structs"
actions_service "forgejo.org/services/actions"
runnerv1 "code.forgejo.org/forgejo/actions-proto/runner/v1"
"connectrpc.com/connect"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -606,6 +608,128 @@ jobs:
})
}
func TestActionsEphemeral(t *testing.T) {
if !setting.Database.Type.IsSQLite3() {
t.Skip()
}
onApplicationRun(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.registerAsEphemeralRepoRunner(t, baseRepo.OwnerName, baseRepo.Name, "mock-runner", []string{"ubuntu-latest"})
// verify CleanupEphemeralRunners does not remove this runner
err := actions_service.CleanupEphemeralRunners(t.Context())
require.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)
_, err = doAPICreatePullRequest(user2APICtx, baseRepo.OwnerName, baseRepo.Name, baseRepo.DefaultBranch, "user2/patch-1")(t)
require.NoError(t, err)
task := runner.fetchTask(t)
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})
require.NoError(t, actionRun.LoadAttributes(t.Context()))
runEvent := map[string]any{}
require.NoError(t, json.Unmarshal([]byte(actionRun.EventPayload), &runEvent))
// verify CleanupEphemeralRunners does not remove this runner
err = actions_service.CleanupEphemeralRunners(t.Context())
require.NoError(t, err)
resp, err := runner.client.runnerServiceClient.FetchTask(t.Context(), connect.NewRequest(&runnerv1.FetchTaskRequest{
TasksVersion: 0,
}))
require.NoError(t, err)
assert.Nil(t, resp.Msg.Task)
// verify CleanupEphemeralRunners does not remove this runner
err = actions_service.CleanupEphemeralRunners(t.Context())
require.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,
}))
require.Error(t, err)
assert.Nil(t, resp)
// create an runner that picks a job and get force cancelled
runnerToBeRemoved := newMockRunner()
runnerToBeRemoved.registerAsEphemeralRepoRunner(t, baseRepo.OwnerName, baseRepo.Name, "mock-runner-to-be-removed", []string{"ubuntu-latest"})
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())
require.NoError(t, err)
runnerToRemove := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: taskToStop.RunnerID})
err = actions_service.StopTask(t.Context(), taskToStop.ID, actions_model.StatusFailure)
require.NoError(t, err)
// verify CleanupEphemeralRunners does remove the custom crafted runner
err = actions_service.CleanupEphemeralRunners(t.Context())
require.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)
})
}
func createActionsTestRepo(t *testing.T, authToken, repoName string, isPrivate bool) *api.Repository {
req := NewRequestWithJSON(t, "POST", "/api/v1/user/repos", &api.CreateRepoOption{
Name: repoName,

View file

@ -72,10 +72,24 @@ func (r *mockRunner) doPing(t *testing.T) {
func (r *mockRunner) doRegister(t *testing.T, name, token string, labels []string) {
r.doPing(t)
resp, err := r.client.runnerServiceClient.Register(t.Context(), connect.NewRequest(&runnerv1.RegisterRequest{
Name: name,
Token: token,
Version: "mock-runner-version",
Labels: labels,
Name: name,
Token: token,
Version: "mock-runner-version",
Labels: labels,
Ephemeral: false,
}))
require.NoError(t, err)
r.client = newMockRunnerClient(resp.Msg.Runner.Uuid, resp.Msg.Runner.Token)
}
func (r *mockRunner) doRegisterEphemeral(t *testing.T, name, token string, labels []string) {
r.doPing(t)
resp, err := r.client.runnerServiceClient.Register(t.Context(), connect.NewRequest(&runnerv1.RegisterRequest{
Name: name,
Token: token,
Version: "mock-runner-version",
Labels: labels,
Ephemeral: true,
}))
require.NoError(t, err)
r.client = newMockRunnerClient(resp.Msg.Runner.Uuid, resp.Msg.Runner.Token)
@ -96,6 +110,21 @@ func (r *mockRunner) registerAsRepoRunner(t *testing.T, ownerName, repoName, run
r.doRegister(t, runnerName, registrationToken.Token, labels)
}
func (r *mockRunner) registerAsEphemeralRepoRunner(t *testing.T, ownerName, repoName, runnerName string, labels []string) {
if !setting.Database.Type.IsSQLite3() {
assert.FailNow(t, "registering a mock runner when using a database other than SQLite leaves leftovers")
}
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, &registrationToken)
r.doRegisterEphemeral(t, runnerName, registrationToken.Token, labels)
}
func (r *mockRunner) maybeFetchTask(t *testing.T) *runnerv1.Task {
resp, err := r.client.runnerServiceClient.FetchTask(t.Context(), connect.NewRequest(&runnerv1.FetchTaskRequest{
TasksVersion: r.lastTasksVersion,

View file

@ -226,12 +226,25 @@ func TestAPIAdminActionsRunnerOperations(t *testing.T) {
Labels: []string{"fedora"},
Status: "offline",
}
runnerFive := &api.ActionRunner{
ID: 130795,
UUID: "16ca1a5c-8024-41f1-be31-e55830263cc6",
Name: "runner-5-ephemeral",
Version: "1.0.0",
OwnerID: 0,
RepoID: 0,
Description: "An ephemeral runner",
Labels: []string{"ephemeral-label"},
Status: "offline",
Ephemeral: true,
}
// There are more runners in the result that originate from the global fixtures. The test ignores them to limit
// the impact of unrelated changes.
assert.Contains(t, runners, runnerOne)
assert.Contains(t, runners, runnerTwo)
assert.Contains(t, runners, runnerThree)
assert.Contains(t, runners, runnerFive)
})
t.Run("Get runners paginated", func(t *testing.T) {
@ -293,6 +306,30 @@ func TestAPIAdminActionsRunnerOperations(t *testing.T) {
assert.Equal(t, runnerFour, runner)
})
t.Run("Get ephemeral runner", func(t *testing.T) {
request := NewRequest(t, "GET", "/api/v1/admin/actions/runners/130795")
request.AddTokenAuth(readToken)
response := MakeRequest(t, request, http.StatusOK)
var runner *api.ActionRunner
DecodeJSON(t, response, &runner)
expectedRunner := &api.ActionRunner{
ID: 130795,
UUID: "16ca1a5c-8024-41f1-be31-e55830263cc6",
Name: "runner-5-ephemeral",
Version: "1.0.0",
OwnerID: 0,
RepoID: 0,
Description: "An ephemeral runner",
Labels: []string{"ephemeral-label"},
Status: "offline",
Ephemeral: true,
}
assert.Equal(t, expectedRunner, runner)
})
t.Run("Delete global runner", func(t *testing.T) {
url := "/api/v1/admin/actions/runners/130791"
@ -339,6 +376,7 @@ func TestAPIAdminActionsRunnerOperations(t *testing.T) {
assert.Positive(t, registerRunnerResponse.ID)
assert.Equal(t, gouuid.Version(4), gouuid.MustParse(registerRunnerResponse.UUID).Version())
assert.Regexp(t, "(?i)^[0-9a-f]{40}$", registerRunnerResponse.Token)
assert.False(t, registerRunnerResponse.Ephemeral)
registeredRunner := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{UUID: registerRunnerResponse.UUID})
assert.Equal(t, registerRunnerResponse.ID, registeredRunner.ID)
@ -351,6 +389,24 @@ func TestAPIAdminActionsRunnerOperations(t *testing.T) {
assert.Empty(t, registeredRunner.Version)
assert.NotEmpty(t, registeredRunner.TokenHash)
assert.NotEmpty(t, registeredRunner.TokenSalt)
assert.False(t, registeredRunner.Ephemeral)
})
t.Run("Register ephemeral runner", func(t *testing.T) {
options := api.RegisterRunnerOptions{Name: "ephemeral-runner", Description: "Ephemeral runner", Ephemeral: true}
request := NewRequestWithJSON(t, "POST", "/api/v1/admin/actions/runners", options)
request.AddTokenAuth(writeToken)
response := MakeRequest(t, request, http.StatusCreated)
var registerRunnerResponse *api.RegisterRunnerResponse
DecodeJSON(t, response, &registerRunnerResponse)
assert.True(t, registerRunnerResponse.Ephemeral)
registeredRunner := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{UUID: registerRunnerResponse.UUID})
assert.Equal(t, registerRunnerResponse.UUID, registeredRunner.UUID)
assert.True(t, registeredRunner.Ephemeral)
})
t.Run("Runner registration does not update runner with identical name", func(t *testing.T) {

View file

@ -117,7 +117,7 @@ func TestAPIOrgActionsRunnerOperations(t *testing.T) {
request.AddTokenAuth(readToken)
response := MakeRequest(t, request, http.StatusOK)
assert.Equal(t, "2", response.Header().Get("X-Total-Count"))
assert.Equal(t, "3", response.Header().Get("X-Total-Count"))
var runners []*api.ActionRunner
DecodeJSON(t, response, &runners)
@ -144,8 +144,20 @@ func TestAPIOrgActionsRunnerOperations(t *testing.T) {
Labels: []string{"fedora"},
Status: "offline",
}
runnerFive := &api.ActionRunner{
ID: 655695,
UUID: "0851ed0a-f0af-4a01-9b98-fc9bf9c1d332",
Name: "runner-5-ephemeral",
Version: "1.0.0",
OwnerID: 3,
RepoID: 0,
Description: "An ephemeral runner",
Labels: []string{"ephemeral-label"},
Status: "offline",
Ephemeral: true,
}
assert.ElementsMatch(t, []*api.ActionRunner{runnerOne, runnerThree}, runners)
assert.ElementsMatch(t, []*api.ActionRunner{runnerOne, runnerThree, runnerFive}, runners)
})
t.Run("Get runners paginated", func(t *testing.T) {
@ -184,6 +196,30 @@ func TestAPIOrgActionsRunnerOperations(t *testing.T) {
assert.Equal(t, runnerOne, runner)
})
t.Run("Get ephemeral runner", func(t *testing.T) {
request := NewRequest(t, "GET", "/api/v1/orgs/org3/actions/runners/655695")
request.AddTokenAuth(readToken)
response := MakeRequest(t, request, http.StatusOK)
var runner *api.ActionRunner
DecodeJSON(t, response, &runner)
expectedRunner := &api.ActionRunner{
ID: 655695,
UUID: "0851ed0a-f0af-4a01-9b98-fc9bf9c1d332",
Name: "runner-5-ephemeral",
Version: "1.0.0",
OwnerID: 3,
RepoID: 0,
Description: "An ephemeral runner",
Labels: []string{"ephemeral-label"},
Status: "offline",
Ephemeral: true,
}
assert.Equal(t, expectedRunner, runner)
})
t.Run("Delete runner", func(t *testing.T) {
url := "/api/v1/orgs/org3/actions/runners/655691"
@ -214,6 +250,7 @@ func TestAPIOrgActionsRunnerOperations(t *testing.T) {
assert.Positive(t, registerRunnerResponse.ID)
assert.Equal(t, gouuid.Version(4), gouuid.MustParse(registerRunnerResponse.UUID).Version())
assert.Regexp(t, "(?i)^[0-9a-f]{40}$", registerRunnerResponse.Token)
assert.False(t, registerRunnerResponse.Ephemeral)
registeredRunner := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{UUID: registerRunnerResponse.UUID})
assert.Equal(t, registerRunnerResponse.ID, registeredRunner.ID)
@ -226,6 +263,24 @@ func TestAPIOrgActionsRunnerOperations(t *testing.T) {
assert.Empty(t, registeredRunner.Version)
assert.NotEmpty(t, registeredRunner.TokenHash)
assert.NotEmpty(t, registeredRunner.TokenSalt)
assert.False(t, registeredRunner.Ephemeral)
})
t.Run("Register ephemeral runner", func(t *testing.T) {
options := api.RegisterRunnerOptions{Name: "ephemeral-runner", Description: "Ephemeral runner", Ephemeral: true}
request := NewRequestWithJSON(t, "POST", "/api/v1/orgs/org3/actions/runners", options)
request.AddTokenAuth(writeToken)
response := MakeRequest(t, request, http.StatusCreated)
var registerRunnerResponse *api.RegisterRunnerResponse
DecodeJSON(t, response, &registerRunnerResponse)
assert.True(t, registerRunnerResponse.Ephemeral)
registeredRunner := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{UUID: registerRunnerResponse.UUID})
assert.Equal(t, registerRunnerResponse.UUID, registeredRunner.UUID)
assert.True(t, registeredRunner.Ephemeral)
})
t.Run("Runner registration does not update runner with identical name", func(t *testing.T) {

View file

@ -398,7 +398,7 @@ func TestAPIRepoActionsRunnerOperations(t *testing.T) {
request.AddTokenAuth(readToken)
response := MakeRequest(t, request, http.StatusOK)
assert.Equal(t, "2", response.Header().Get("X-Total-Count"))
assert.Equal(t, "3", response.Header().Get("X-Total-Count"))
var runners []*api.ActionRunner
DecodeJSON(t, response, &runners)
@ -425,8 +425,20 @@ func TestAPIRepoActionsRunnerOperations(t *testing.T) {
Labels: []string{"fedora"},
Status: "offline",
}
runnerFive := &api.ActionRunner{
ID: 899255,
UUID: "96639646-67b2-4bcb-9142-fde1ab8498cf",
Name: "runner-5-repository-ephemeral",
Version: "1.0.0",
OwnerID: 0,
RepoID: 62,
Description: "An ephemeral runner",
Labels: []string{"ephemeral-label"},
Status: "offline",
Ephemeral: true,
}
assert.ElementsMatch(t, []*api.ActionRunner{runnerOne, runnerThree}, runners)
assert.ElementsMatch(t, []*api.ActionRunner{runnerOne, runnerThree, runnerFive}, runners)
})
t.Run("Get runners paginated", func(t *testing.T) {
@ -465,6 +477,30 @@ func TestAPIRepoActionsRunnerOperations(t *testing.T) {
assert.Equal(t, runnerOne, runner)
})
t.Run("Get ephemeral runner", func(t *testing.T) {
request := NewRequest(t, "GET", "/api/v1/repos/user2/test_workflows/actions/runners/899255")
request.AddTokenAuth(readToken)
response := MakeRequest(t, request, http.StatusOK)
var runner *api.ActionRunner
DecodeJSON(t, response, &runner)
expectedRunner := &api.ActionRunner{
ID: 899255,
UUID: "96639646-67b2-4bcb-9142-fde1ab8498cf",
Name: "runner-5-repository-ephemeral",
Version: "1.0.0",
OwnerID: 0,
RepoID: 62,
Description: "An ephemeral runner",
Labels: []string{"ephemeral-label"},
Status: "offline",
Ephemeral: true,
}
assert.Equal(t, expectedRunner, runner)
})
t.Run("Delete runner", func(t *testing.T) {
url := "/api/v1/repos/user2/test_workflows/actions/runners/899253"
@ -496,6 +532,7 @@ func TestAPIRepoActionsRunnerOperations(t *testing.T) {
assert.Positive(t, registerRunnerResponse.ID)
assert.Equal(t, gouuid.Version(4), gouuid.MustParse(registerRunnerResponse.UUID).Version())
assert.Regexp(t, "(?i)^[0-9a-f]{40}$", registerRunnerResponse.Token)
assert.False(t, registerRunnerResponse.Ephemeral)
registeredRunner := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{UUID: registerRunnerResponse.UUID})
assert.Equal(t, registerRunnerResponse.ID, registeredRunner.ID)
@ -508,6 +545,25 @@ func TestAPIRepoActionsRunnerOperations(t *testing.T) {
assert.Empty(t, registeredRunner.Version)
assert.NotEmpty(t, registeredRunner.TokenHash)
assert.NotEmpty(t, registeredRunner.TokenSalt)
assert.False(t, registeredRunner.Ephemeral)
})
t.Run("Register ephemeral runner", func(t *testing.T) {
options := api.RegisterRunnerOptions{Name: "ephemeral-runner", Description: "Ephemeral runner", Ephemeral: true}
requestURL := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runners", repo1.OwnerName, repo1.Name)
request := NewRequestWithJSON(t, "POST", requestURL, options)
request.AddTokenAuth(writeToken)
response := MakeRequest(t, request, http.StatusCreated)
var registerRunnerResponse *api.RegisterRunnerResponse
DecodeJSON(t, response, &registerRunnerResponse)
assert.True(t, registerRunnerResponse.Ephemeral)
registeredRunner := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{UUID: registerRunnerResponse.UUID})
assert.Equal(t, registerRunnerResponse.UUID, registeredRunner.UUID)
assert.True(t, registeredRunner.Ephemeral)
})
t.Run("Runner registration does not update runner with identical name", func(t *testing.T) {

View file

@ -115,7 +115,7 @@ func TestAPIUserActionsRunnerOperations(t *testing.T) {
request.AddTokenAuth(readToken)
response := MakeRequest(t, request, http.StatusOK)
assert.Equal(t, "2", response.Header().Get("X-Total-Count"))
assert.Equal(t, "3", response.Header().Get("X-Total-Count"))
var runners []*api.ActionRunner
DecodeJSON(t, response, &runners)
@ -142,8 +142,20 @@ func TestAPIUserActionsRunnerOperations(t *testing.T) {
Labels: []string{"fedora"},
Status: "offline",
}
runnerFive := &api.ActionRunner{
ID: 71305,
UUID: "3ca04a95-3e75-4e48-8b7a-63427ebcf3b8",
Name: "runner-5-user-ephemeral",
Version: "1.0.0",
OwnerID: 2,
RepoID: 0,
Description: "An ephemeral runner",
Labels: []string{"ephemeral-label"},
Status: "offline",
Ephemeral: true,
}
assert.ElementsMatch(t, []*api.ActionRunner{runnerOne, runnerThree}, runners)
assert.ElementsMatch(t, []*api.ActionRunner{runnerOne, runnerThree, runnerFive}, runners)
})
t.Run("Get runners paginated", func(t *testing.T) {
@ -182,6 +194,30 @@ func TestAPIUserActionsRunnerOperations(t *testing.T) {
assert.Equal(t, runnerThree, runner)
})
t.Run("Get ephemeral runner", func(t *testing.T) {
request := NewRequest(t, "GET", "/api/v1/user/actions/runners/71305")
request.AddTokenAuth(readToken)
response := MakeRequest(t, request, http.StatusOK)
var runner *api.ActionRunner
DecodeJSON(t, response, &runner)
expectedRunner := &api.ActionRunner{
ID: 71305,
UUID: "3ca04a95-3e75-4e48-8b7a-63427ebcf3b8",
Name: "runner-5-user-ephemeral",
Version: "1.0.0",
OwnerID: 2,
RepoID: 0,
Description: "An ephemeral runner",
Labels: []string{"ephemeral-label"},
Status: "offline",
Ephemeral: true,
}
assert.Equal(t, expectedRunner, runner)
})
t.Run("Delete runner", func(t *testing.T) {
url := "/api/v1/user/actions/runners/71303"
@ -212,6 +248,7 @@ func TestAPIUserActionsRunnerOperations(t *testing.T) {
assert.Positive(t, registerRunnerResponse.ID)
assert.Equal(t, gouuid.Version(4), gouuid.MustParse(registerRunnerResponse.UUID).Version())
assert.Regexp(t, "(?i)^[0-9a-f]{40}$", registerRunnerResponse.Token)
assert.False(t, registerRunnerResponse.Ephemeral)
registeredRunner := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{UUID: registerRunnerResponse.UUID})
assert.Equal(t, registerRunnerResponse.ID, registeredRunner.ID)
@ -224,6 +261,24 @@ func TestAPIUserActionsRunnerOperations(t *testing.T) {
assert.Empty(t, registeredRunner.Version)
assert.NotEmpty(t, registeredRunner.TokenHash)
assert.NotEmpty(t, registeredRunner.TokenSalt)
assert.False(t, registeredRunner.Ephemeral)
})
t.Run("Register ephemeral runner", func(t *testing.T) {
options := api.RegisterRunnerOptions{Name: "ephemeral-runner", Description: "Ephemeral runner", Ephemeral: true}
request := NewRequestWithJSON(t, "POST", "/api/v1/user/actions/runners", options)
request.AddTokenAuth(writeToken)
response := MakeRequest(t, request, http.StatusCreated)
var registerRunnerResponse *api.RegisterRunnerResponse
DecodeJSON(t, response, &registerRunnerResponse)
assert.True(t, registerRunnerResponse.Ephemeral)
registeredRunner := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{UUID: registerRunnerResponse.UUID})
assert.Equal(t, registerRunnerResponse.UUID, registeredRunner.UUID)
assert.True(t, registeredRunner.Ephemeral)
})
t.Run("Runner registration does not update runner with identical name", func(t *testing.T) {

View file

@ -0,0 +1,170 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"context"
"net/url"
"testing"
actions_model "forgejo.org/models/actions"
org_model "forgejo.org/models/organization"
"forgejo.org/models/unittest"
user_model "forgejo.org/models/user"
"forgejo.org/modules/setting"
"forgejo.org/modules/util"
actions_service "forgejo.org/services/actions"
org_service "forgejo.org/services/org"
repo_service "forgejo.org/services/repository"
user_service "forgejo.org/services/user"
runnerv1 "code.forgejo.org/forgejo/actions-proto/runner/v1"
"connectrpc.com/connect"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Test that the ephemeral runner is deleted when the task is finished
func TestEphemeralRunnerDeletionByTaskCompletion(t *testing.T) {
if !setting.Database.Type.IsSQLite3() {
t.Skip()
}
defer unittest.OverrideFixtures("tests/integration/fixtures/TestEphemeralRunner")()
onApplicationRun(t, func(t *testing.T, u *url.URL) {
// Verify runner exists before the test
runner, err := actions_model.GetRunnerByID(context.Background(), 10000008)
require.NoError(t, err)
require.NotNil(t, runner)
require.True(t, runner.Ephemeral, "runner should be ephemeral")
// Verify task exists and is running
task := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 10054})
assert.Equal(t, actions_model.StatusRunning, task.Status)
assert.Equal(t, int64(10000008), task.RunnerID)
// Token can be found in models/fixtures/action_runner.yml with id: 10000008
runnerClient := newMockRunnerClient(
runner.UUID,
"mysuuupersecrettoekn",
)
// Finish the Task
resp, err := runnerClient.runnerServiceClient.UpdateTask(
context.Background(),
connect.NewRequest(&runnerv1.UpdateTaskRequest{
State: &runnerv1.TaskState{
Id: task.ID,
Result: runnerv1.Result_RESULT_SUCCESS,
},
}),
)
require.NoError(t, err)
require.NotNil(t, resp)
assert.Equal(t, runnerv1.Result_RESULT_SUCCESS, resp.Msg.State.Result)
// Expect the ephemeral runner has been deleted
_, err = actions_model.GetRunnerByID(context.Background(), 10000008)
assert.ErrorIs(t, err, util.ErrNotExist, "ephemeral runner should be deleted after task completion")
})
}
func TestEphemeralRunnerDeletedByTaskZombieCleanup(t *testing.T) {
if !setting.Database.Type.IsSQLite3() {
t.Skip()
}
defer unittest.OverrideFixtures("tests/integration/fixtures/TestEphemeralRunner")()
onApplicationRun(t, func(t *testing.T, u *url.URL) {
// Verify runner exists before the test
runner, err := actions_model.GetRunnerByID(context.Background(), 10000011)
require.NoError(t, err)
require.NotNil(t, runner)
require.True(t, runner.Ephemeral, "runner should be ephemeral")
// Verify zombie task exists and is running
task := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 10055})
assert.Equal(t, actions_model.StatusRunning, task.Status)
assert.Equal(t, int64(10000011), task.RunnerID)
// Run zombie task cleanup
err = actions_service.StopZombieTasks(context.Background())
require.NoError(t, err)
// Expect the ephemeral runner has been deleted
_, err = actions_model.GetRunnerByID(context.Background(), 10000011)
assert.ErrorIs(t, err, util.ErrNotExist, "ephemeral runner should be deleted after zombie task cleanup")
})
}
func TestEphemeralRunnerDeletionOnRepositoryDeletion(t *testing.T) {
if !setting.Database.Type.IsSQLite3() {
t.Skip()
}
defer unittest.OverrideFixtures("tests/integration/fixtures/TestEphemeralRunner")()
onApplicationRun(t, func(t *testing.T, u *url.URL) {
runner, err := actions_model.GetRunnerByID(t.Context(), 10000008)
require.NoError(t, err)
assert.Equal(t, int64(0), runner.OwnerID, "runner should not start in user scope")
assert.NotEqual(t, int64(0), runner.RepoID, "runner should start in repo scope")
task := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 10054})
assert.Equal(t, actions_model.StatusRunning, task.Status)
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
err = repo_service.DeleteRepositoryDirectly(t.Context(), user, task.RepoID, true)
require.NoError(t, err)
_, err = actions_model.GetRunnerByID(t.Context(), 10000008)
assert.ErrorIs(t, err, util.ErrNotExist)
})
}
// Test that the ephemeral runner is deleted when a user is deleted
func TestEphemeralRunnerDeletionOnUserDeletion(t *testing.T) {
if !setting.Database.Type.IsSQLite3() {
t.Skip()
}
defer unittest.OverrideFixtures("tests/integration/fixtures/TestEphemeralRunner")()
onApplicationRun(t, func(t *testing.T, u *url.URL) {
runner, err := actions_model.GetRunnerByID(t.Context(), 10000012)
require.NoError(t, err)
assert.NotEqual(t, int64(0), runner.OwnerID, "runner should start in user scope")
assert.Equal(t, int64(0), runner.RepoID, "runner should not start in repo scope")
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
err = user_service.DeleteUser(t.Context(), user, true)
require.NoError(t, err)
unittest.AssertNotExistsBean(t, runner)
})
}
// Test that the ephemeral runner is deleted when an organization is deleted
func TestEphemeralRunnerDeletionOnOrgDeletion(t *testing.T) {
if !setting.Database.Type.IsSQLite3() {
t.Skip()
}
defer unittest.OverrideFixtures("tests/integration/fixtures/TestEphemeralRunner")()
onApplicationRun(t, func(t *testing.T, u *url.URL) {
runner, err := actions_model.GetRunnerByID(t.Context(), 10000013)
require.NoError(t, err)
assert.NotEqual(t, int64(0), runner.OwnerID, "runner should start in org scope")
assert.Equal(t, int64(0), runner.RepoID, "runner should not start in repo scope")
org := unittest.AssertExistsAndLoadBean(t, &org_model.Organization{ID: runner.OwnerID})
err = org_service.DeleteOrganization(t.Context(), org, true)
require.NoError(t, err)
unittest.AssertNotExistsBean(t, runner)
})
}

View file

@ -33,4 +33,14 @@
repo_id: 62
description: ""
agent_labels: ["nixos"]
deleted: 0
deleted: 0
- id: 130795
uuid: "16ca1a5c-8024-41f1-be31-e55830263cc6"
name: "runner-5-ephemeral"
version: "1.0.0"
owner_id: 0
repo_id: 0
description: "An ephemeral runner"
agent_labels: ["ephemeral-label"]
ephemeral: true
deleted: 0

View file

@ -34,3 +34,13 @@
description: ""
agent_labels: []
deleted: 0
- id: 655695
uuid: "0851ed0a-f0af-4a01-9b98-fc9bf9c1d332"
name: "runner-5-ephemeral"
version: "1.0.0"
owner_id: 3
repo_id: 0
description: "An ephemeral runner"
agent_labels: ["ephemeral-label"]
ephemeral: true
deleted: 0

View file

@ -34,3 +34,13 @@
description: ""
agent_labels: []
deleted: 0
- id: 899255
uuid: "96639646-67b2-4bcb-9142-fde1ab8498cf"
name: "runner-5-repository-ephemeral"
version: "1.0.0"
owner_id: 0
repo_id: 62
description: "An ephemeral runner"
agent_labels: ["ephemeral-label"]
ephemeral: true
deleted: 0

View file

@ -34,3 +34,13 @@
description: ""
agent_labels: []
deleted: 0
- id: 71305
uuid: "3ca04a95-3e75-4e48-8b7a-63427ebcf3b8"
name: "runner-5-user-ephemeral"
version: "1.0.0"
owner_id: 2
repo_id: 0
description: "An ephemeral runner"
agent_labels: ["ephemeral-label"]
ephemeral: true
deleted: 0

View file

@ -0,0 +1,61 @@
-
id: 10895
title: "running workflow_dispatch run"
repo_id: 64
owner_id: 3
workflow_id: "running.yaml"
index: 3
trigger_user_id: 2
ref: "refs/heads/main"
commit_sha: "c2d72f548424103f01ee1dc02889c1e2bff816b0"
event: "workflow_dispatch"
trigger_event: "workflow_dispatch"
is_fork_pull_request: 0
status: 6 # running
started: 1683636528
created: 1683636108
updated: 1683636626
need_approval: 0
approved_by: 0
event_payload: '{}'
-
id: 10896
title: "running workflow_dispatch run"
repo_id: 64
owner_id: 3
workflow_id: "running.yaml"
index: 4
trigger_user_id: 2
ref: "refs/heads/main"
commit_sha: "c2d72f548424103f01ee1dc02889c1e2bff816b0"
event: "workflow_dispatch"
trigger_event: "workflow_dispatch"
is_fork_pull_request: 0
status: 6 # running
started: 1683636528
created: 1683636108
updated: 1683636626
need_approval: 0
approved_by: 0
event_payload: '{}'
-
id: 10897
title: "waiting workflow for user runner scope test"
repo_id: 64
owner_id: 10
workflow_id: "waiting.yaml"
index: 5
trigger_user_id: 10
ref: "refs/heads/main"
commit_sha: "c2d72f548424103f01ee1dc02889c1e2bff816b0"
event: "push"
is_fork_pull_request: 0
status: 5 # waiting
started: 0
created: 1683636108
updated: 1683636108
need_approval: 0
approved_by: 0
event_payload: '{"head_commit":{"id":"c2d72f548424103f01ee1dc02889c1e2bff816b0"}}'

View file

@ -0,0 +1,46 @@
-
id: 10398
run_id: 10895
repo_id: 64
owner_id: 3
commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0
is_fork_pull_request: 0
name: job_3
attempt: 1
job_id: job_3
runs_on: '["fedora"]'
task_id: 10054
status: 6
started: 1683636528
stopped: 1683636626
-
id: 10399
run_id: 10896
repo_id: 64
owner_id: 3
commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0
is_fork_pull_request: 0
name: job_3
runs_on: '["fedora"]'
attempt: 1
job_id: job_3
task_id: 10055
status: 6
started: 1683636528
stopped: 1683636626
-
id: 10401
run_id: 10897
repo_id: 64
owner_id: 10
commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0
is_fork_pull_request: 0
name: job_for_ephemeral_runner_test
attempt: 0
job_id: job_ephemeral_test
task_id: 0
status: 5
runs_on: '["job_for_org_runner_scope_test"]'
workflow_payload: '{"jobs":{"job_ephemeral_test":{"runs-on":["job_for_org_runner_scope_test"],"steps":[{"run":"echo test"}]}}}'
started: 0
stopped: 0

View file

@ -0,0 +1,75 @@
-
id: 10000008
name: ephemeral_runner_to_be_deleted
uuid: 3FF231BD-FBB7-4E4B-9602-E6F28363EF20
token_hash: e379fa0089b8829085497fd5231f170f4e5ab44fa3dc7c4e2b5b5ce72e01fab2930d9e2ea3a65183f2db93c00e3a3dc9cae2
token_salt: saltysaltsalt
# token: mysuuupersecrettoekn
ephemeral: true
version: "1.0.0"
owner_id: 0
repo_id: 64
description: "This runner is going to be deleted"
agent_labels: '["job_for_org_runner_scope_test"]'
- # ephemeral org runner for scope change test
id: 10000010
name: ephemeral_org_runner_for_scope_test
uuid: a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d
token_hash: c0ff52ff7e163e2bd7ed8654858d4532b7852f767347c413b50deed646886e3255d56f232785cab6bdc1eb667707eaa1e70e
token_salt: saltysaltsalt
# token: 379fa0089b8829085497fd5231f170f4e
ephemeral: true
version: "1.0.0"
owner_id: 10
repo_id: 0
description: "Ephemeral org runner for testing scope change"
agent_labels: '["job_for_org_runner_scope_test"]'
created: 1716104432
updated: 1716104432
deleted: ~
- # ephemeral runner for zombie task test
id: 10000011
name: ephemeral_runner_for_zombie_test
uuid: b2c3d4e5-f6a7-5b6c-9d0e-1f2a3b4c5d6e
token_hash: notvalid
token_salt: notvalid
ephemeral: true
version: "1.0.0"
owner_id: 0
repo_id: 64
description: "Ephemeral runner for testing zombie cleanup"
agent_labels: '["zombie-test"]'
created: 1716104432
updated: 1716104432
deleted: ~
- # ephemeral user-scoped runner for user deletion test
id: 10000012
name: ephemeral_user_runner_for_deletion_test
uuid: c3d4e5f6-a7b8-6c7d-0e1f-2a3b4c5d6e7f
token_hash: f480ga1190c9930196508ge6342g271g5f6bc56c878458d524c61feff757f8746166e67c343896dbc7cde2fc778808fbb2f81
token_salt: saltysaltsalt
# token: userdeletiontoken123456
ephemeral: true
version: "1.0.0"
owner_id: 2
repo_id: 0
description: "Ephemeral user runner for testing user deletion"
agent_labels: '["user-deletion-test"]'
created: 1716104432
updated: 1716104432
deleted: ~
- id: 10000013
name: ephemeral_org_runner_for_deletion_test
uuid: d4e5f6a7-b8c9-7d8e-1f2a-3b4c5d6e7f8a
token_hash: fix-the-hash-if-you-want-to-use-runner-api
token_salt: saltysaltsalt
# token: orgdeletiontoken123456
ephemeral: true
version: "1.0.0"
owner_id: 3
repo_id: 0
description: "Ephemeral org runner for testing org deletion"
agent_labels: '["org-deletion-test"]'
created: 1716104432
updated: 1716104432
deleted: ~

View file

@ -0,0 +1,41 @@
-
id: 10054
job_id: 10398
attempt: 1
runner_id: 10000008
status: 6 # running
started: 1683636528
stopped: 1683636626
repo_id: 64
owner_id: 3
commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0
is_fork_pull_request: 0
token_hash: f8d3962425466b6709b9ac51446f93260c54afe8e7b6d3686e34f991fb8a8953822b0deed86fe41a103f34bc48dbc4784222
token_salt: ffffffffff
token_last_eight: ffffffff
log_filename: artifact-test2/2f/47.log
log_in_storage: 1
log_length: 707
log_size: 90179
log_expired: 0
-
id: 10055
job_id: 10399
attempt: 1
runner_id: 10000011
status: 6 # running
started: 946684810
stopped: 0
repo_id: 64
owner_id: 3
commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0
is_fork_pull_request: 0
token_hash: a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0
token_salt: zombietask
token_last_eight: zombietk
log_filename: zombie-task/55.log
log_in_storage: 0
log_length: 100
log_size: 1000
log_expired: 0
updated: 946684810

View file

@ -0,0 +1,6 @@
-
id: 121
repo_id: 64
type: 10
config: "{}"
created_unix: 946684810

View file

@ -0,0 +1,28 @@
-
id: 64
owner_id: 10
owner_name: user10
lower_name: test_ephemeral_runner
name: test_ephemeral_runner
default_branch: main
num_watches: 0
num_stars: 0
num_forks: 0
num_milestones: 0
num_closed_milestones: 0
num_projects: 0
num_closed_projects: 0
is_private: false
is_empty: false
is_archived: false
is_mirror: false
status: 0
is_fork: false
fork_id: 0
is_template: false
template_id: 0
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false
topics: '[]'