diff --git a/cmd/forgejo/actions.go b/cmd/forgejo/actions.go index f520924c20..309b9801cb 100644 --- a/cmd/forgejo/actions.go +++ b/cmd/forgejo/actions.go @@ -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) } diff --git a/models/actions/forgejo.go b/models/actions/forgejo.go index ce3f8b0c8b..a55066fe3a 100644 --- a/models/actions/forgejo.go +++ b/models/actions/forgejo.go @@ -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") diff --git a/models/actions/forgejo_test.go b/models/actions/forgejo_test.go index 5702068c1b..2170b0927a 100644 --- a/models/actions/forgejo_test.go +++ b/models/actions/forgejo_test.go @@ -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) } diff --git a/models/actions/runner.go b/models/actions/runner.go index 2b5fcd77cc..3761cb6ccf 100644 --- a/models/actions/runner.go +++ b/models/actions/runner.go @@ -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"` diff --git a/models/actions/task.go b/models/actions/task.go index 3cf8a3af04..b0e6bb0f15 100644 --- a/models/actions/task.go +++ b/models/actions/task.go @@ -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) diff --git a/models/forgejo_migrations/v15b_add-ephemeral_runner.go b/models/forgejo_migrations/v15b_add-ephemeral_runner.go new file mode 100644 index 0000000000..746763aa66 --- /dev/null +++ b/models/forgejo_migrations/v15b_add-ephemeral_runner.go @@ -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 +} diff --git a/modules/structs/repo_actions.go b/modules/structs/repo_actions.go index 69e616eed9..324ea474fd 100644 --- a/modules/structs/repo_actions.go +++ b/modules/structs/repo_actions.go @@ -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"` } diff --git a/modules/structs/runner.go b/modules/structs/runner.go index e744355b30..cecbb72400 100644 --- a/modules/structs/runner.go +++ b/modules/structs/runner.go @@ -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"` } diff --git a/routers/api/actions/runner/runner.go b/routers/api/actions/runner/runner.go index ed48ee320b..e0bd8f3d8d 100644 --- a/routers/api/actions/runner/runner.go +++ b/routers/api/actions/runner/runner.go @@ -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{ diff --git a/routers/api/v1/shared/runners.go b/routers/api/v1/shared/runners.go index ecfbbcda69..ad470215e9 100644 --- a/routers/api/v1/shared/runners.go +++ b/routers/api/v1/shared/runners.go @@ -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) } diff --git a/services/actions/cleanup.go b/services/actions/cleanup.go index 918be0f185..d1603a4808 100644 --- a/services/actions/cleanup.go +++ b/services/actions/cleanup.go @@ -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) diff --git a/services/actions/cleanup_test.go b/services/actions/cleanup_test.go index 4a847ced23..b7f3e8a842 100644 --- a/services/actions/cleanup_test.go +++ b/services/actions/cleanup_test.go @@ -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}) + }) +} diff --git a/services/actions/task.go b/services/actions/task.go index bbe17ac8b3..34b838f2a3 100644 --- a/services/actions/task.go +++ b/services/actions/task.go @@ -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 } diff --git a/services/convert/convert.go b/services/convert/convert.go index 00e1742178..65fa701ffe 100644 --- a/services/convert/convert.go +++ b/services/convert/convert.go @@ -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 diff --git a/services/repository/delete.go b/services/repository/delete.go index 9eca085da3..ebebdef3f1 100644 --- a/services/repository/delete.go +++ b/services/repository/delete.go @@ -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}, diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index a82fa503f0..976c25afa7 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -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", diff --git a/tests/integration/actions_job_test.go b/tests/integration/actions_job_test.go index 91c61fe258..d252d122a0 100644 --- a/tests/integration/actions_job_test.go +++ b/tests/integration/actions_job_test.go @@ -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, diff --git a/tests/integration/actions_runner_test.go b/tests/integration/actions_runner_test.go index 83e544464e..22191020dc 100644 --- a/tests/integration/actions_runner_test.go +++ b/tests/integration/actions_runner_test.go @@ -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, ®istrationToken) + 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, diff --git a/tests/integration/api_admin_actions_test.go b/tests/integration/api_admin_actions_test.go index 10e4660caa..02644401fd 100644 --- a/tests/integration/api_admin_actions_test.go +++ b/tests/integration/api_admin_actions_test.go @@ -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, ®isterRunnerResponse) + + 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) { diff --git a/tests/integration/api_org_actions_test.go b/tests/integration/api_org_actions_test.go index c29798d03c..f21723a751 100644 --- a/tests/integration/api_org_actions_test.go +++ b/tests/integration/api_org_actions_test.go @@ -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, ®isterRunnerResponse) + + 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) { diff --git a/tests/integration/api_repo_actions_test.go b/tests/integration/api_repo_actions_test.go index 896eacb106..35cbc5d864 100644 --- a/tests/integration/api_repo_actions_test.go +++ b/tests/integration/api_repo_actions_test.go @@ -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, ®isterRunnerResponse) + + 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) { diff --git a/tests/integration/api_user_actions_test.go b/tests/integration/api_user_actions_test.go index 73a4060c0c..bdb763d5b2 100644 --- a/tests/integration/api_user_actions_test.go +++ b/tests/integration/api_user_actions_test.go @@ -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, ®isterRunnerResponse) + + 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) { diff --git a/tests/integration/ephemeral_actions_runner_deletion_test.go b/tests/integration/ephemeral_actions_runner_deletion_test.go new file mode 100644 index 0000000000..9e995c4864 --- /dev/null +++ b/tests/integration/ephemeral_actions_runner_deletion_test.go @@ -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) + }) +} diff --git a/tests/integration/fixtures/TestAPIGlobalActionsRunnerOperations/action_runner.yml b/tests/integration/fixtures/TestAPIGlobalActionsRunnerOperations/action_runner.yml index ef16984be0..3054b236fb 100644 --- a/tests/integration/fixtures/TestAPIGlobalActionsRunnerOperations/action_runner.yml +++ b/tests/integration/fixtures/TestAPIGlobalActionsRunnerOperations/action_runner.yml @@ -33,4 +33,14 @@ repo_id: 62 description: "" agent_labels: ["nixos"] - deleted: 0 \ No newline at end of file + 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 diff --git a/tests/integration/fixtures/TestAPIOrgActionsRunnerOperations/action_runner.yml b/tests/integration/fixtures/TestAPIOrgActionsRunnerOperations/action_runner.yml index f39cac24be..c469099e03 100644 --- a/tests/integration/fixtures/TestAPIOrgActionsRunnerOperations/action_runner.yml +++ b/tests/integration/fixtures/TestAPIOrgActionsRunnerOperations/action_runner.yml @@ -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 diff --git a/tests/integration/fixtures/TestAPIRepoActionsRunnerOperations/action_runner.yml b/tests/integration/fixtures/TestAPIRepoActionsRunnerOperations/action_runner.yml index 1f1edb919e..808448d772 100644 --- a/tests/integration/fixtures/TestAPIRepoActionsRunnerOperations/action_runner.yml +++ b/tests/integration/fixtures/TestAPIRepoActionsRunnerOperations/action_runner.yml @@ -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 diff --git a/tests/integration/fixtures/TestAPIUserActionsRunnerOperations/action_runner.yml b/tests/integration/fixtures/TestAPIUserActionsRunnerOperations/action_runner.yml index be6ace174f..3bafcc4546 100644 --- a/tests/integration/fixtures/TestAPIUserActionsRunnerOperations/action_runner.yml +++ b/tests/integration/fixtures/TestAPIUserActionsRunnerOperations/action_runner.yml @@ -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 diff --git a/tests/integration/fixtures/TestEphemeralRunner/action_run.yml b/tests/integration/fixtures/TestEphemeralRunner/action_run.yml new file mode 100644 index 0000000000..0ea5927b5c --- /dev/null +++ b/tests/integration/fixtures/TestEphemeralRunner/action_run.yml @@ -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"}}' diff --git a/tests/integration/fixtures/TestEphemeralRunner/action_run_job.yml b/tests/integration/fixtures/TestEphemeralRunner/action_run_job.yml new file mode 100644 index 0000000000..7b834cfbab --- /dev/null +++ b/tests/integration/fixtures/TestEphemeralRunner/action_run_job.yml @@ -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 diff --git a/tests/integration/fixtures/TestEphemeralRunner/action_runner.yml b/tests/integration/fixtures/TestEphemeralRunner/action_runner.yml new file mode 100644 index 0000000000..ed86f5d47d --- /dev/null +++ b/tests/integration/fixtures/TestEphemeralRunner/action_runner.yml @@ -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: ~ diff --git a/tests/integration/fixtures/TestEphemeralRunner/action_task.yml b/tests/integration/fixtures/TestEphemeralRunner/action_task.yml new file mode 100644 index 0000000000..2352df3ecd --- /dev/null +++ b/tests/integration/fixtures/TestEphemeralRunner/action_task.yml @@ -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 diff --git a/tests/integration/fixtures/TestEphemeralRunner/repo_unit.yml b/tests/integration/fixtures/TestEphemeralRunner/repo_unit.yml new file mode 100644 index 0000000000..4f219cb25a --- /dev/null +++ b/tests/integration/fixtures/TestEphemeralRunner/repo_unit.yml @@ -0,0 +1,6 @@ +- + id: 121 + repo_id: 64 + type: 10 + config: "{}" + created_unix: 946684810 diff --git a/tests/integration/fixtures/TestEphemeralRunner/repository.yml b/tests/integration/fixtures/TestEphemeralRunner/repository.yml new file mode 100644 index 0000000000..dbd9c12460 --- /dev/null +++ b/tests/integration/fixtures/TestEphemeralRunner/repository.yml @@ -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: '[]'