feat: add form-based runner management (#11516)

Forgejo Runner is deprecating the runner registration token. It is too powerful, requires tooling, and is unnecessary. As a consequence, users need new mechanisms for managing runners in Forgejo. https://codeberg.org/forgejo/forgejo/pulls/10677 added an HTTP API for runner registration. This PR adds the ability to manage runners using Forgejo's web interface.

Runners can be added, modified, and deleted. It is also possible to regenerate a runner's token. When a runner is added or a runner's token is regenerated, setup instructions are displayed. They explain how to alter Forgejo Runner's configuration file or how to launch `forgejo-runner daemon` (yet to be implemented). The existing details page has been overhauled and is now accessible to all users that are allowed to use a particular runner. The details page displays additional information that had to be removed from the list of runners due to space constraints. The task list is filtered. That means it only lists jobs of the respective repository, user, or organization.

The runner registration token has been marked as deprecated.

See https://code.forgejo.org/forgejo/forgejo-actions-feature-requests/issues/88 for context and design considerations.

## 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 for Go changes

(can be removed for JavaScript changes)

- I added test coverage for Go changes...
  - [x] in their respective `*_test.go` for unit tests.
  - [x] in the `tests/integration` directory if it involves interactions with a live Forgejo server.
- I ran...
  - [x] `make pr-go` before pushing

### Tests for JavaScript changes

(can be removed for Go changes)

- I added test coverage for JavaScript changes...
  - [ ] in `web_src/js/*.test.js` if it can be unit tested.
  - [x] 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.
- [x] I did not document these changes and I do not expect someone else to do it.

### Release notes

- [x] This change will be noticed by a Forgejo user or admin (feature, bug fix, performance, etc.). I suggest to include a release note for this change.
- [ ] This change is not visible to a Forgejo user or admin (refactor, dependency upgrade, etc.). I think there is no need to add a release note for this change.

*The decision if the pull request will be shown in the release notes is up to the mergers / release team.*

The content of the `release-notes/<pull request number>.md` file will serve as the basis for the release notes. If the file does not exist, the title of the pull request will be used instead.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/11516
Reviewed-by: Mathieu Fenniak <mfenniak@noreply.codeberg.org>
Co-authored-by: Andreas Ahlenstorf <andreas@ahlenstorf.ch>
Co-committed-by: Andreas Ahlenstorf <andreas@ahlenstorf.ch>
This commit is contained in:
Andreas Ahlenstorf 2026-03-12 02:14:45 +01:00 committed by Mathieu Fenniak
parent c3908ba349
commit 2963572845
38 changed files with 2030 additions and 303 deletions

View file

@ -0,0 +1,36 @@
- id: 719931
uuid: "9e0eb762-cbdf-4725-9c90-4298568a2a77"
name: "runner-1"
version: "dev"
owner_id: 3 # Owned by org3
repo_id: 0
description: "A superb runner"
agent_labels: ["debian", "gpu"]
deleted: 0
- id: 719932
uuid: "b0a0a168-1b08-4365-95dd-e65040ca384d"
name: "runner-2"
version: "11.3.1"
owner_id: 2 # Owned by user2
repo_id: 0
description: "An exclusive runner"
agent_labels: ["docker"]
deleted: 0
- id: 719933
uuid: "974f9caf-ee64-4022-a56b-67b28ea06a0d"
name: "runner-3"
version: "12.2.0"
owner_id: 0
repo_id: 0
description: "A runner for everyone"
agent_labels: ["docker"]
deleted: 0
- id: 719934
uuid: "022650a8-e999-4a3d-afb0-21f75233798b"
name: "runner-4"
version: "12.1.0"
owner_id: 0
repo_id: 32
description: ""
agent_labels: ["debian"]
deleted: 0

View file

@ -127,6 +127,18 @@ func (r *ActionRunner) IsOnline() bool {
return false
}
func (r *ActionRunner) IsActive() bool {
return r.Status() == runnerv1.RunnerStatus_RUNNER_STATUS_ACTIVE
}
func (r *ActionRunner) IsIdle() bool {
return r.Status() == runnerv1.RunnerStatus_RUNNER_STATUS_IDLE
}
func (r *ActionRunner) IsOffline() bool {
return r.Status() == runnerv1.RunnerStatus_RUNNER_STATUS_OFFLINE
}
// Editable checks if the runner is editable by the user
func (r *ActionRunner) Editable(ownerID, repoID int64) bool {
if ownerID == 0 && repoID == 0 {
@ -266,6 +278,31 @@ func GetRunnerByID(ctx context.Context, id int64) (*ActionRunner, error) {
return &runner, nil
}
// GetAvailableRunnerByID is like GetRunnerByID, but it only finds the runner if it is accessible to the given owner or
// repository. If it is not, util.ErrNotExist will be returned even if the runner exists.
func GetAvailableRunnerByID(ctx context.Context, id, ownerID, repoID int64) (*ActionRunner, error) {
query := db.GetEngine(ctx).Where("id=?", id)
if repoID > 0 {
cond := builder.NewCond().And(builder.Eq{"repo_id": repoID})
cond = cond.Or(builder.Eq{"owner_id": builder.Select("owner_id").From("repository").Where(builder.Eq{"id": repoID})})
cond = cond.Or(builder.Eq{"repo_id": 0, "owner_id": 0})
query = query.And(cond)
} else if ownerID > 0 { // ownerID is ignored if repoID is set
cond := builder.NewCond().And(builder.Eq{"owner_id": ownerID}).Or(builder.Eq{"repo_id": 0, "owner_id": 0})
query = query.And(cond)
}
var runner ActionRunner
has, err := query.Get(&runner)
if err != nil {
return nil, err
} else if !has {
return nil, fmt.Errorf("runner with ID %d: %w", id, util.ErrNotExist)
}
return &runner, nil
}
// UpdateRunner updates runner's information.
func UpdateRunner(ctx context.Context, r *ActionRunner, cols ...string) error {
e := db.GetEngine(ctx)

View file

@ -10,6 +10,7 @@ import (
auth_model "forgejo.org/models/auth"
"forgejo.org/models/db"
"forgejo.org/models/repo"
"forgejo.org/models/unittest"
"forgejo.org/modules/timeutil"
@ -235,3 +236,141 @@ func TestRunnerEditable(t *testing.T) {
})
}
}
func TestRunner_GetAvailableRunnerByID(t *testing.T) {
defer unittest.OverrideFixtures("models/actions/TestRunner_GetAvailableRunnerByID")()
require.NoError(t, unittest.PrepareTestDatabase())
repository32 := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: 32, OwnerID: 3})
repository1 := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: 1, OwnerID: 2})
runner1 := unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 719931, OwnerID: 3, RepoID: 0}) // Owned by org3
runner2 := unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 719932, OwnerID: 2, RepoID: 0}) // Owned by user2
runner3 := unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 719933, OwnerID: 0, RepoID: 0})
runner4 := unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 719934, OwnerID: 0, RepoID: repository32.ID})
testCases := []struct {
name string
runner *ActionRunner
ownerID int64
repoID int64
expectedError string
}{
{
name: "Organization runner",
runner: runner1,
ownerID: 3,
repoID: 0,
expectedError: "",
},
{
name: "Organization runner visible to admins",
runner: runner1,
ownerID: 0,
repoID: 0,
expectedError: "",
},
{
name: "Organization runner invisible to different owner",
runner: runner1,
ownerID: 2,
repoID: 0,
expectedError: fmt.Sprintf("runner with ID %d: resource does not exist", runner1.ID),
},
{
name: "Organization runner visible to its repositories",
runner: runner1,
ownerID: 0,
repoID: repository32.ID,
expectedError: "",
},
{
name: "Organization runner invisible to repositories owned by somebody else",
runner: runner1,
ownerID: 0,
repoID: repository1.ID,
expectedError: fmt.Sprintf("runner with ID %d: resource does not exist", runner1.ID),
},
{
name: "User runner",
runner: runner2,
ownerID: 2,
repoID: 0,
expectedError: "",
},
{
name: "User runner invisible to different user",
runner: runner2,
ownerID: 1,
repoID: 0,
expectedError: fmt.Sprintf("runner with ID %d: resource does not exist", runner2.ID),
},
{
name: "User runner visible to repository owned by user",
runner: runner2,
ownerID: 0,
repoID: repository1.ID,
expectedError: "",
},
{
name: "User runner invisible to repository owned by different user",
runner: runner2,
ownerID: 0,
repoID: repository32.ID,
expectedError: fmt.Sprintf("runner with ID %d: resource does not exist", runner2.ID),
},
{
name: "Global runner",
runner: runner3,
ownerID: 0,
repoID: 0,
expectedError: "",
},
{
name: "Global runner is visible to any user",
runner: runner3,
ownerID: 2,
repoID: 0,
expectedError: "",
},
{
name: "Global runner is visible to any repository",
runner: runner3,
ownerID: 0,
repoID: repository32.ID,
expectedError: "",
},
{
name: "Repository runner",
runner: runner4,
ownerID: 0,
repoID: repository32.ID,
expectedError: "",
},
{
name: "Repository runner is visible to admins",
runner: runner4,
ownerID: 0,
repoID: 0,
expectedError: "",
},
{
name: "Repository runner is invisible to repository owner",
runner: runner4,
ownerID: repository32.OwnerID,
repoID: 0,
expectedError: fmt.Sprintf("runner with ID %d: resource does not exist", runner4.ID),
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
_, err := GetAvailableRunnerByID(t.Context(), testCase.runner.ID, testCase.ownerID, testCase.repoID)
if testCase.expectedError == "" {
require.NoError(t, err)
} else {
assert.ErrorContains(t, err, testCase.expectedError)
}
})
}
}

View file

@ -421,13 +421,26 @@
"actions.runners.status.idle": "Idle",
"actions.runners.status.active": "Active",
"actions.runners.status.offline": "Offline",
"actions.runners.id": "ID",
"actions.runners.uuid": "UUID",
"actions.runners.name": "Name",
"actions.runners.owner_type": "Type",
"actions.runners.description": "Description",
"actions.runners.token": "Token",
"actions.runners.ephemeral": "Ephemeral",
"actions.runners.labels": "Labels",
"actions.runners.last_online": "Last online time",
"actions.runners.runner_title": "Runner",
"actions.runners.list_runners.details_column": "Details",
"actions.runners.list_runners.edit_column": "Edit",
"actions.runners.list_runners.delete_column": "Delete",
"actions.runners.list_runners.details_button": "Details",
"actions.runners.list_runners.details_button_aria": "Show details of %s",
"actions.runners.list_runners.delete_button": "Delete",
"actions.runners.list_runners.delete_button_aria": "Delete %s",
"actions.runners.list_runners.edit_button": "Edit",
"actions.runners.list_runners.edit_button_aria": "Edit %s",
"actions.runners.runner_title": "Runner %s",
"actions.runners.ephemeral.yes": "yes",
"actions.runners.ephemeral.no": "no",
"actions.runners.task_list": "Recent tasks on this runner",
"actions.runners.task_list.no_tasks": "There are no tasks yet.",
"actions.runners.task_list.run": "Run",
@ -435,17 +448,47 @@
"actions.runners.task_list.repository": "Repository",
"actions.runners.task_list.commit": "Commit",
"actions.runners.task_list.done_at": "Done at",
"actions.runners.edit_runner": "Edit runner",
"actions.runners.update_runner.button": "Save changes",
"actions.runners.edit_runner_button": "Edit runner",
"actions.runners.create_runner.page_title": "Create new runner",
"actions.runners.create_runner.title": "Create new runner",
"actions.runners.create_runner.properties_fieldset": "Properties",
"actions.runners.create_runner.name_label": "Name*",
"actions.runners.create_runner.description_label": "Description",
"actions.runners.create_runner.create_button": "Create",
"actions.runners.create_runner.cancel_button": "Cancel",
"actions.runners.edit_runner.page_title": "Edit runner %s",
"actions.runners.edit_runner.title": "Edit runner %s",
"actions.runners.edit_runner.properties_fieldset": "Properties",
"actions.runners.edit_runner.properties_options": "Options",
"actions.runners.edit_runner.name_label": "Name*",
"actions.runners.edit_runner.description_label": "Description",
"actions.runners.edit_runner.regenerate_token_label": "Regenerate token",
"actions.runners.edit_runner.regenerate_token_help": "The existing token will be invalidated immediately. You will receive a new token on the next page.",
"actions.runners.edit_runner.save_button": "Save",
"actions.runners.edit_runner.cancel_button": "Cancel",
"actions.runners.runner_setup.title": "Set up runner %s",
"actions.runners.show_registration_token": "Show registration token",
"actions.runners.update_runner.success": "Runner edited successfully",
"actions.runners.update_runner.failed": "Failed to edit runner",
"actions.runners.delete_runner.button": "Delete this runner",
"actions.runners.delete_runner.success": "Runner deleted successfully",
"actions.runners.delete_runner.failed": "Failed to delete runner",
"actions.runners.delete_runner.header": "Confirm to delete this runner",
"actions.runners.delete_runner.notice": "If a task is running on this runner, it will be terminated and marked as failed. It may break building workflow.",
"actions.runners.delete_runner.success": "Runner deleted successfully",
"actions.runners.delete_runner.failed": "Failed to delete runner",
"actions.runners.runner_details.page_title": "Runner %s",
"actions.runners.runner_details.labels_note": "The runner's labels are defined in the configuration file of Forgejo Runner or passed as command line option. They are updated every time Forgejo Runner establishes a connection to Forgejo.",
"actions.runners.runner_setup.page_title": "Set up runner %s",
"actions.runners.runner_setup.list_of_runners_link": "List of runners",
"actions.runners.runner_setup.last_chance_copying_token": "Copy the token now as you will not be able to see it again!",
"actions.runners.runner_setup.button_copy_uuid_aria": "Copy runner UUID",
"actions.runners.runner_setup.button_copy_token_aria": "Copy runner token",
"actions.runners.runner_setup.heading_using_configuration": "Using the runner configuration file",
"actions.runners.runner_setup.configuration_snippet_aria": "Snippet to insert into the runner configuration",
"actions.runners.runner_setup.program_options_snippet_aria": "How to invoke forgejo-runner",
"actions.runners.runner_setup.instruction_replace_connection_name": "Replace the connection name (<code>forgejo</code> in the example) with a value of your liking.",
"actions.runners.runner_setup.heading_using_options": "Using program options",
"actions.runners.runner_setup.instruction_advanced_configurations": "For configuring Forgejo Runner running in containers or advanced configurations, see the <a href=\"https://TO-BE-REPLACED.COM\">documentation</a>.",
"actions.runners.none": "No runners available",
"actions.runners.version": "Version",
"actions.runners.reset_registration_token.token": "Registration Token (Deprecated)",
"actions.runners.reset_registration_token.button": "Reset registration token",
"actions.runners.reset_registration_token.success": "Runner registration token reset successfully",
"actions.runs.run_attempt_label": "Run attempt #%[1]s (%[2]s)",
@ -512,5 +555,6 @@
"editor.toggle_case": "Toggle case sensitivity",
"editor.toggle_regex": "Toggle using regular expressions",
"editor.toggle_whole_word": "Toggle matching whole words",
"form.RunnerName": "Name",
"meta.last_line": "Thank you for translating Forgejo! This line isn't seen by the users but it serves other purposes in the translation management. You can place a fun fact in the translation instead of translating it."
}

View file

@ -5,7 +5,7 @@ package setting
import (
"errors"
"net/http"
"fmt"
"net/url"
actions_model "forgejo.org/models/actions"
@ -18,88 +18,109 @@ import (
)
const (
// TODO: Separate secrets from runners when layout is ready
tplRepoRunners base.TplName = "repo/settings/actions"
tplOrgRunners base.TplName = "org/settings/actions"
tplAdminRunners base.TplName = "admin/actions"
tplUserRunners base.TplName = "user/settings/actions"
tplRepoRunnerEdit base.TplName = "repo/settings/runner_edit"
tplOrgRunnerEdit base.TplName = "org/settings/runners_edit"
tplAdminRunnerEdit base.TplName = "admin/runners/edit"
tplUserRunnerEdit base.TplName = "user/settings/runner_edit"
tplAdminRunnerCreate base.TplName = "admin/runners/create"
tplAdminRunnerDetails base.TplName = "admin/runners/details"
tplAdminRunnerEdit base.TplName = "admin/runners/edit"
tplAdminRunnerSetup base.TplName = "admin/runners/setup"
tplAdminRunners base.TplName = "admin/actions"
tplOrgRunnerCreate base.TplName = "org/settings/runners_create"
tplOrgRunnerDetails base.TplName = "org/settings/runners_details"
tplOrgRunnerEdit base.TplName = "org/settings/runners_edit"
tplOrgRunnerSetup base.TplName = "org/settings/runners_setup"
tplOrgRunners base.TplName = "org/settings/actions"
tplRepoRunnerCreate base.TplName = "repo/settings/runner_create"
tplRepoRunnerDetails base.TplName = "repo/settings/runner_details"
tplRepoRunnerEdit base.TplName = "repo/settings/runner_edit"
tplRepoRunnerSetup base.TplName = "repo/settings/runner_setup"
tplRepoRunners base.TplName = "repo/settings/actions"
tplUserRunnerCreate base.TplName = "user/settings/runner_create"
tplUserRunnerDetails base.TplName = "user/settings/runner_details"
tplUserRunnerEdit base.TplName = "user/settings/runner_edit"
tplUserRunnerSetup base.TplName = "user/settings/runner_setup"
tplUserRunners base.TplName = "user/settings/actions"
)
type runnersCtx struct {
OwnerID int64
RepoID int64
IsRepo bool
IsOrg bool
IsAdmin bool
IsUser bool
RunnersTemplate base.TplName
RunnerEditTemplate base.TplName
RedirectLink string
OwnerID int64
RepoID int64
IsRepo bool
IsOrg bool
IsAdmin bool
IsUser bool
RunnerCreateTemplate base.TplName
RunnerDetailsTemplate base.TplName
RunnerEditTemplate base.TplName
RunnerSetupTemplate base.TplName
RunnersTemplate base.TplName
RedirectLink string
}
func getRunnersCtx(ctx *context.Context) (*runnersCtx, error) {
if ctx.Data["PageIsRepoSettings"] == true {
return &runnersCtx{
RepoID: ctx.Repo.Repository.ID,
OwnerID: 0,
IsRepo: true,
RunnersTemplate: tplRepoRunners,
RunnerEditTemplate: tplRepoRunnerEdit,
RedirectLink: ctx.Repo.RepoLink + "/settings/actions/runners/",
RepoID: ctx.Repo.Repository.ID,
OwnerID: 0,
IsRepo: true,
RunnerCreateTemplate: tplRepoRunnerCreate,
RunnerDetailsTemplate: tplRepoRunnerDetails,
RunnerEditTemplate: tplRepoRunnerEdit,
RunnerSetupTemplate: tplRepoRunnerSetup,
RunnersTemplate: tplRepoRunners,
RedirectLink: ctx.Repo.RepoLink + "/settings/actions/runners/",
}, nil
}
if ctx.Data["PageIsOrgSettings"] == true {
err := shared_user.LoadHeaderCount(ctx)
if err != nil {
ctx.ServerError("LoadHeaderCount", err)
return nil, nil
return nil, fmt.Errorf("could not load project and package counts: %w", err)
}
return &runnersCtx{
RepoID: 0,
OwnerID: ctx.Org.Organization.ID,
IsOrg: true,
RunnersTemplate: tplOrgRunners,
RunnerEditTemplate: tplOrgRunnerEdit,
RedirectLink: ctx.Org.OrgLink + "/settings/actions/runners/",
RepoID: 0,
OwnerID: ctx.Org.Organization.ID,
IsOrg: true,
RunnerCreateTemplate: tplOrgRunnerCreate,
RunnerDetailsTemplate: tplOrgRunnerDetails,
RunnerEditTemplate: tplOrgRunnerEdit,
RunnerSetupTemplate: tplOrgRunnerSetup,
RunnersTemplate: tplOrgRunners,
RedirectLink: ctx.Org.OrgLink + "/settings/actions/runners/",
}, nil
}
if ctx.Data["PageIsAdmin"] == true {
return &runnersCtx{
RepoID: 0,
OwnerID: 0,
IsAdmin: true,
RunnersTemplate: tplAdminRunners,
RunnerEditTemplate: tplAdminRunnerEdit,
RedirectLink: setting.AppSubURL + "/admin/actions/runners/",
RepoID: 0,
OwnerID: 0,
IsAdmin: true,
RunnerCreateTemplate: tplAdminRunnerCreate,
RunnerDetailsTemplate: tplAdminRunnerDetails,
RunnerEditTemplate: tplAdminRunnerEdit,
RunnerSetupTemplate: tplAdminRunnerSetup,
RunnersTemplate: tplAdminRunners,
RedirectLink: setting.AppSubURL + "/admin/actions/runners/",
}, nil
}
if ctx.Data["PageIsUserSettings"] == true {
return &runnersCtx{
OwnerID: ctx.Doer.ID,
RepoID: 0,
IsUser: true,
RunnersTemplate: tplUserRunners,
RunnerEditTemplate: tplUserRunnerEdit,
RedirectLink: setting.AppSubURL + "/user/settings/actions/runners/",
OwnerID: ctx.Doer.ID,
RepoID: 0,
IsUser: true,
RunnerCreateTemplate: tplUserRunnerCreate,
RunnerDetailsTemplate: tplUserRunnerDetails,
RunnerEditTemplate: tplUserRunnerEdit,
RunnerSetupTemplate: tplUserRunnerSetup,
RunnersTemplate: tplUserRunners,
RedirectLink: setting.AppSubURL + "/user/settings/actions/runners/",
}, nil
}
return nil, errors.New("unable to set Runners context")
}
// Runners render settings/actions/runners page for repo level
// Runners renders the list of all available runners.
func Runners(ctx *context.Context) {
ctx.Data["PageIsSharedSettingsRunners"] = true
ctx.Data["Title"] = ctx.Tr("actions.actions")
ctx.Data["PageType"] = "runners"
rCtx, err := getRunnersCtx(ctx)
if err != nil {
ctx.ServerError("getRunnersCtx", err)
@ -126,60 +147,114 @@ func Runners(ctx *context.Context) {
opts.OwnerID = rCtx.OwnerID
opts.WithAvailable = true
}
actions_shared.RunnersList(ctx, opts)
ctx.HTML(http.StatusOK, rCtx.RunnersTemplate)
ctx.Data["RunnersListLink"] = rCtx.RedirectLink
actions_shared.RunnersList(ctx, rCtx.RunnersTemplate, opts)
}
// RunnersEdit renders runner edit page for repository level
func RunnersEdit(ctx *context.Context) {
ctx.Data["PageIsSharedSettingsRunners"] = true
ctx.Data["Title"] = ctx.Tr("actions.runners.edit_runner")
// RunnersDetails renders a read-only view of the most important properties of a runner. It is accessible to every user
// that can use that particular runner.
func RunnersDetails(ctx *context.Context) {
rCtx, err := getRunnersCtx(ctx)
if err != nil {
ctx.ServerError("getRunnersCtx", err)
return
}
runnerID := ctx.ParamsInt64(":runnerid")
page := ctx.FormInt("page")
if page <= 1 {
page = 1
}
actions_shared.RunnerDetails(ctx, page,
ctx.ParamsInt64(":runnerid"), rCtx.OwnerID, rCtx.RepoID,
)
ctx.HTML(http.StatusOK, rCtx.RunnerEditTemplate)
ctx.Data["RunnersListLink"] = rCtx.RedirectLink
actions_shared.RunnerDetails(ctx, runnerID, rCtx.OwnerID, rCtx.RepoID, rCtx.RunnerDetailsTemplate, page)
}
// RunnersCreate renders the form for creating a new runner.
func RunnersCreate(ctx *context.Context) {
rCtx, err := getRunnersCtx(ctx)
if err != nil {
ctx.ServerError("getRunnersCtx", err)
return
}
ctx.Data["RunnersListLink"] = rCtx.RedirectLink
actions_shared.RunnerCreate(ctx, rCtx.RunnerCreateTemplate)
}
// RunnersCreatePost handles the form submitted by RunnersCreate.
func RunnersCreatePost(ctx *context.Context) {
rCtx, err := getRunnersCtx(ctx)
if err != nil {
ctx.ServerError("getRunnersCtx", err)
return
}
ctx.Data["RunnersListLink"] = rCtx.RedirectLink
actions_shared.RunnerCreatePost(ctx, rCtx.OwnerID, rCtx.RepoID, rCtx.RunnerCreateTemplate, rCtx.RunnerSetupTemplate)
}
// RunnersEdit renders the form for changing an existing runner.
func RunnersEdit(ctx *context.Context) {
rCtx, err := getRunnersCtx(ctx)
if err != nil {
ctx.ServerError("getRunnersCtx", err)
return
}
ctx.Data["RunnersListLink"] = rCtx.RedirectLink
actions_shared.RunnerEdit(ctx, ctx.ParamsInt64(":runnerid"), rCtx.OwnerID, rCtx.RepoID, rCtx.RunnerEditTemplate)
}
// RunnersEditPost handles the form submitted by RunnersEdit.
func RunnersEditPost(ctx *context.Context) {
rCtx, err := getRunnersCtx(ctx)
if err != nil {
ctx.ServerError("getRunnersCtx", err)
return
}
actions_shared.RunnerDetailsEditPost(ctx, ctx.ParamsInt64(":runnerid"),
rCtx.OwnerID, rCtx.RepoID,
rCtx.RedirectLink+url.PathEscape(ctx.Params(":runnerid")))
ctx.Data["RunnersListLink"] = rCtx.RedirectLink
runnerID := ctx.ParamsInt64(":runnerid")
redirectURL := rCtx.RedirectLink + url.PathEscape(ctx.Params(":runnerid"))
actions_shared.RunnerEditPost(ctx, runnerID, rCtx.OwnerID, rCtx.RepoID, rCtx.RunnerEditTemplate,
rCtx.RunnerSetupTemplate, redirectURL)
}
// ResetRunnerRegistrationToken handles the request to reset the runner registration token.
func ResetRunnerRegistrationToken(ctx *context.Context) {
rCtx, err := getRunnersCtx(ctx)
if err != nil {
ctx.ServerError("getRunnersCtx", err)
return
}
ctx.Data["RunnersListLink"] = rCtx.RedirectLink
actions_shared.RunnerResetRegistrationToken(ctx, rCtx.OwnerID, rCtx.RepoID, rCtx.RedirectLink)
}
// RunnerDeletePost response for deleting runner
// RunnerDeletePost handles the request to delete a runner.
func RunnerDeletePost(ctx *context.Context) {
rCtx, err := getRunnersCtx(ctx)
if err != nil {
ctx.ServerError("getRunnersCtx", err)
return
}
actions_shared.RunnerDeletePost(ctx, ctx.ParamsInt64(":runnerid"), rCtx.OwnerID, rCtx.RepoID, rCtx.RedirectLink, rCtx.RedirectLink+url.PathEscape(ctx.Params(":runnerid")))
ctx.Data["RunnersListLink"] = rCtx.RedirectLink
runnerID := ctx.ParamsInt64(":runnerid")
successRedirectURL := rCtx.RedirectLink
failureRedirectURL := rCtx.RedirectLink
actions_shared.RunnerDeletePost(ctx, runnerID, rCtx.OwnerID, rCtx.RepoID, successRedirectURL, failureRedirectURL)
}
func RedirectToDefaultSetting(ctx *context.Context) {

View file

@ -5,19 +5,24 @@ package actions
import (
"errors"
"net/http"
actions_model "forgejo.org/models/actions"
"forgejo.org/models/db"
"forgejo.org/modules/base"
"forgejo.org/modules/log"
"forgejo.org/modules/optional"
"forgejo.org/modules/setting"
"forgejo.org/modules/util"
"forgejo.org/modules/web"
"forgejo.org/services/context"
"forgejo.org/services/forms"
gouuid "github.com/google/uuid"
)
// RunnersList prepares data for runners list
func RunnersList(ctx *context.Context, opts actions_model.FindRunnerOptions) {
// RunnersList renders the list of runners.
func RunnersList(ctx *context.Context, template base.TplName, opts actions_model.FindRunnerOptions) {
runners, count, err := db.FindAndCount[actions_model.ActionRunner](ctx, opts)
if err != nil {
ctx.ServerError("CountRunners", err)
@ -52,6 +57,9 @@ func RunnersList(ctx *context.Context, opts actions_model.FindRunnerOptions) {
return
}
ctx.Data["PageIsSharedSettingsRunners"] = true
ctx.Data["Title"] = ctx.Tr("actions.actions")
ctx.Data["PageType"] = "runners"
ctx.Data["Keyword"] = opts.Filter
ctx.Data["Runners"] = runners
ctx.Data["Total"] = count
@ -63,26 +71,24 @@ func RunnersList(ctx *context.Context, opts actions_model.FindRunnerOptions) {
pager := context.NewPagination(int(count), opts.PageSize, opts.Page, 5)
ctx.Data["Page"] = pager
ctx.HTML(http.StatusOK, template)
}
// RunnerDetails prepares data for runners edit page
func RunnerDetails(ctx *context.Context, page int, runnerID, ownerID, repoID int64) {
runner, err := actions_model.GetRunnerByID(ctx, runnerID)
if err != nil {
ctx.ServerError("GetRunnerByID", err)
// RunnerDetails displays detail information about each runner. The page is purely informational and visible to everyone
// who is allowed to use a runner.
func RunnerDetails(ctx *context.Context, runnerID, ownerID, repoID int64, template base.TplName, page int) {
runner, err := actions_model.GetAvailableRunnerByID(ctx, runnerID, ownerID, repoID)
if errors.Is(err, util.ErrNotExist) {
ctx.NotFound("GetAvailableRunnerByID", err)
return
} else if err != nil {
ctx.ServerError("GetAvailableRunnerByID", err)
return
}
if err := runner.LoadAttributes(ctx); err != nil {
ctx.ServerError("LoadAttributes", err)
return
}
if !runner.Editable(ownerID, repoID) {
err = errors.New("no permission to edit this runner")
ctx.NotFound("RunnerDetails", err)
return
}
ctx.Data["Runner"] = runner
opts := actions_model.FindTaskOptions{
ListOptions: db.ListOptions{
@ -90,6 +96,8 @@ func RunnerDetails(ctx *context.Context, page int, runnerID, ownerID, repoID int
PageSize: 30,
},
RunnerID: runner.ID,
OwnerID: ownerID,
RepoID: repoID,
}
tasks, count, err := db.FindAndCount[actions_model.ActionTask](ctx, opts)
@ -103,42 +111,147 @@ func RunnerDetails(ctx *context.Context, page int, runnerID, ownerID, repoID int
return
}
ctx.Data["PageIsSharedSettingsRunners"] = true
ctx.Data["RunnerOwnerID"] = ownerID
ctx.Data["RunnerRepoID"] = repoID
ctx.Data["Title"] = ctx.Tr("actions.runners.runner_details.page_title", runner.Name)
ctx.Data["Runner"] = runner
ctx.Data["Tasks"] = tasks
pager := context.NewPagination(int(count), opts.PageSize, opts.Page, 5)
ctx.Data["Page"] = pager
ctx.HTML(http.StatusOK, template)
}
// RunnerDetailsEditPost response for edit runner details
func RunnerDetailsEditPost(ctx *context.Context, runnerID, ownerID, repoID int64, redirectTo string) {
runner, err := actions_model.GetRunnerByID(ctx, runnerID)
// RunnerCreate displays a form for creating a new runner.
func RunnerCreate(ctx *context.Context, template base.TplName) {
ctx.Data["PageIsSharedSettingsRunners"] = true
ctx.Data["Title"] = ctx.Tr("actions.runners.create_runner.page_title")
ctx.HTML(http.StatusOK, template)
}
// RunnerCreatePost handles the form submitted by RunnerCreate.
func RunnerCreatePost(ctx *context.Context, ownerID, repoID int64, template, successTemplate base.TplName) {
form := web.GetForm(ctx).(*forms.CreateRunnerForm)
runner := actions_model.ActionRunner{
UUID: gouuid.New().String(),
Name: form.RunnerName,
OwnerID: ownerID,
RepoID: repoID,
Description: form.RunnerDescription,
Ephemeral: false,
}
runner.GenerateToken()
ctx.Data["PageIsSharedSettingsRunners"] = true
ctx.Data["Title"] = ctx.Tr("actions.runners.runner_setup.page_title", runner.Name)
ctx.Data["AppURL"] = setting.AppURL
ctx.Data["Runner"] = runner
ctx.Data["RunnerOwnerID"] = ownerID
ctx.Data["RunnerRepoID"] = repoID
if ctx.HasError() {
ctx.HTML(http.StatusOK, template)
return
}
err := actions_model.CreateRunner(ctx, &runner)
if err != nil {
log.Warn("RunnerDetailsEditPost.GetRunnerByID failed: %v, url: %s", err, ctx.Req.URL)
ctx.ServerError("RunnerDetailsEditPost.GetRunnerByID", err)
ctx.ServerError("CreateRunner", err)
return
}
ctx.HTML(http.StatusOK, successTemplate)
}
// RunnerEdit displays a form to modify the given runner.
func RunnerEdit(ctx *context.Context, runnerID, ownerID, repoID int64, template base.TplName) {
runner, err := actions_model.GetAvailableRunnerByID(ctx, runnerID, ownerID, repoID)
if errors.Is(err, util.ErrNotExist) {
ctx.NotFound("GetAvailableRunnerByID", err)
return
} else if err != nil {
ctx.ServerError("GetAvailableRunnerByID", err)
return
}
if err := runner.LoadAttributes(ctx); err != nil {
ctx.ServerError("LoadAttributes", err)
return
}
if !runner.Editable(ownerID, repoID) {
ctx.NotFound("RunnerDetailsEditPost.Editable", util.NewPermissionDeniedErrorf("no permission to edit this runner"))
err = errors.New("no permission to edit this runner")
ctx.NotFound("RunnerDetails", err)
return
}
form := web.GetForm(ctx).(*forms.EditRunnerForm)
runner.Description = form.Description
ctx.Data["PageIsSharedSettingsRunners"] = true
ctx.Data["Title"] = ctx.Tr("actions.runners.edit_runner.page_title", runner.Name)
ctx.Data["Runner"] = runner
ctx.Data["RunnerOwnerID"] = ownerID
ctx.Data["RunnerRepoID"] = repoID
ctx.HTML(http.StatusOK, template)
}
err = actions_model.UpdateRunner(ctx, runner, "description")
// RunnerEditPost handles the form submitted by RunnerEdit.
func RunnerEditPost(ctx *context.Context, runnerID, ownerID, repoID int64, template, successTemplate base.TplName, redirectTo string) {
runner, err := actions_model.GetAvailableRunnerByID(ctx, runnerID, ownerID, repoID)
if errors.Is(err, util.ErrNotExist) {
ctx.NotFound("GetAvailableRunnerByID", err)
return
} else if err != nil {
ctx.ServerError("GetAvailableRunnerByID", err)
return
}
if !runner.Editable(ownerID, repoID) {
ctx.NotFound("RunnerEditPost.Editable", util.NewPermissionDeniedErrorf("no permission to edit this runner"))
return
}
ctx.Data["PageIsSharedSettingsRunners"] = true
ctx.Data["Title"] = ctx.Tr("actions.runners.runner_setup.page_title", runner.Name)
ctx.Data["AppURL"] = setting.AppURL
ctx.Data["Runner"] = runner
ctx.Data["RunnerOwnerID"] = ownerID
ctx.Data["RunnerRepoID"] = repoID
form := web.GetForm(ctx).(*forms.EditRunnerForm)
runner.Name = form.RunnerName
runner.Description = form.RunnerDescription
if ctx.HasError() {
ctx.HTML(http.StatusOK, template)
return
}
if !form.RegenerateToken {
err = actions_model.UpdateRunner(ctx, runner, "name", "description")
if err != nil {
log.Warn("RunnerEditPost.UpdateRunner failed: %v, url: %s", err, ctx.Req.URL)
ctx.Flash.Warning(ctx.Tr("actions.runners.update_runner.failed"))
ctx.Redirect(redirectTo)
return
}
log.Debug("RunnerEditPost success: %s", ctx.Req.URL)
ctx.Flash.Success(ctx.Tr("actions.runners.update_runner.success"))
ctx.Redirect(redirectTo)
return
}
runner.GenerateToken()
err = actions_model.UpdateRunner(ctx, runner, "name", "description", "token_hash", "token_salt")
if err != nil {
log.Warn("RunnerDetailsEditPost.UpdateRunner failed: %v, url: %s", err, ctx.Req.URL)
log.Warn("RunnerEditPost.UpdateRunner failed: %v, url: %s", err, ctx.Req.URL)
ctx.Flash.Warning(ctx.Tr("actions.runners.update_runner.failed"))
ctx.Redirect(redirectTo)
return
}
log.Debug("RunnerDetailsEditPost success: %s", ctx.Req.URL)
ctx.Flash.Success(ctx.Tr("actions.runners.update_runner.success"))
ctx.Redirect(redirectTo)
ctx.HTML(http.StatusOK, successTemplate)
}
// RunnerResetRegistrationToken reset registration token
// RunnerResetRegistrationToken resets the runner registration token.
func RunnerResetRegistrationToken(ctx *context.Context, ownerID, repoID int64, redirectTo string) {
optOwnerID := optional.None[int64]()
if ownerID != 0 {
@ -159,10 +272,8 @@ func RunnerResetRegistrationToken(ctx *context.Context, ownerID, repoID int64, r
ctx.Redirect(redirectTo)
}
// RunnerDeletePost response for deleting a runner
func RunnerDeletePost(ctx *context.Context, runnerID, ownerID, repoID int64,
successRedirectTo, failedRedirectTo string,
) {
// RunnerDeletePost handles the request for deleting a particular runner.
func RunnerDeletePost(ctx *context.Context, runnerID, ownerID, repoID int64, successRedirectTo, failedRedirectTo string) {
runner, err := actions_model.GetRunnerByID(ctx, runnerID)
if err != nil {
ctx.ServerError("GetRunnerByID", err)
@ -176,6 +287,7 @@ func RunnerDeletePost(ctx *context.Context, runnerID, ownerID, repoID int64,
if err := actions_model.DeleteRunner(ctx, runner); err != nil {
log.Warn("DeleteRunnerPost.UpdateRunner failed: %v, url: %s", err, ctx.Req.URL)
ctx.Flash.Warning(ctx.Tr("actions.runners.delete_runner.failed"))
ctx.JSONRedirect(failedRedirectTo)

View file

@ -25,14 +25,15 @@ func TestRunnerDetails(t *testing.T) {
t.Run("permission denied", func(t *testing.T) {
ctx, resp := contexttest.MockContext(t, "/admin/actions/runners")
RunnerDetails(ctx, 1, runner.ID, user.ID, 0)
assert.Equal(t, http.StatusNotFound, resp.Code)
RunnerDetails(ctx, runner.ID, user.ID, 0, "admin/runners/details", 1)
assert.Equal(t, http.StatusOK, resp.Code)
assert.Empty(t, ctx.GetData()["Tasks"])
})
t.Run("first page", func(t *testing.T) {
ctx, resp := contexttest.MockContext(t, "/admin/actions/runners")
page := 1
RunnerDetails(ctx, page, runner.ID, 0, 0)
RunnerDetails(ctx, runner.ID, 0, 0, "admin/runners/details", page)
require.Equal(t, http.StatusOK, resp.Code)
assert.Len(t, ctx.GetData()["Tasks"], 30)
})
@ -40,7 +41,7 @@ func TestRunnerDetails(t *testing.T) {
t.Run("second and last page", func(t *testing.T) {
ctx, resp := contexttest.MockContext(t, "/admin/actions/runners")
page := 2
RunnerDetails(ctx, page, runner.ID, 0, 0)
RunnerDetails(ctx, runner.ID, 0, 0, "admin/runners/details", page)
require.Equal(t, http.StatusOK, resp.Code)
assert.Len(t, ctx.GetData()["Tasks"], 10)
})

View file

@ -461,7 +461,12 @@ func registerRoutes(m *web.Route) {
addSettingsRunnersRoutes := func() {
m.Group("/runners", func() {
m.Get("", repo_setting.Runners)
m.Combo("/{runnerid}").Get(repo_setting.RunnersEdit).
m.Combo("/new").
Get(repo_setting.RunnersCreate).
Post(web.Bind(forms.CreateRunnerForm{}), repo_setting.RunnersCreatePost)
m.Get("/{runnerid}", repo_setting.RunnersDetails)
m.Combo("/{runnerid}/edit").
Get(repo_setting.RunnersEdit).
Post(web.Bind(forms.EditRunnerForm{}), repo_setting.RunnersEditPost)
m.Post("/{runnerid}/delete", repo_setting.RunnerDeletePost)
m.Get("/reset_registration_token", repo_setting.ResetRunnerRegistrationToken)

View file

@ -12,12 +12,26 @@ import (
"code.forgejo.org/go-chi/binding"
)
// EditRunnerForm form for admin to create runner
type EditRunnerForm struct {
Description string
// CreateRunnerForm needs to be filled in by the user to create a new runner.
type CreateRunnerForm struct {
RunnerName string `binding:"Required;MaxSize(255)"`
RunnerDescription string
}
// Validate validates form fields
// Validate validates the submitted form.
func (f *CreateRunnerForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
ctx := context.GetValidateContext(req)
return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
}
// EditRunnerForm can be filled in by the user to change an existing runner.
type EditRunnerForm struct {
RunnerName string `binding:"Required;MaxSize(255)"`
RunnerDescription string
RegenerateToken bool
}
// Validate validates the submitted form.
func (f *EditRunnerForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
ctx := context.GetValidateContext(req)
return middleware.Validate(errs, ctx.Data, f, ctx.Locale)

View file

@ -0,0 +1,5 @@
{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin runners")}}
<div class="admin-setting-content">
{{template "shared/actions/runner_create" .}}
</div>
{{template "admin/layout_footer" .}}

View file

@ -0,0 +1,5 @@
{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin runners")}}
<div class="admin-setting-content">
{{template "shared/actions/runner_details" .}}
</div>
{{template "admin/layout_footer" .}}

View file

@ -1,5 +1,5 @@
{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin runners")}}
<div class="admin-setting-content">
{{template "shared/actions/runner_edit" .}}
</div>
<div class="admin-setting-content">
{{template "shared/actions/runner_edit" .}}
</div>
{{template "admin/layout_footer" .}}

View file

@ -0,0 +1,5 @@
{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin runners")}}
<div class="admin-setting-content">
{{template "shared/actions/runner_setup" .}}
</div>
{{template "admin/layout_footer" .}}

View file

@ -0,0 +1,5 @@
{{template "org/settings/layout_head" (dict "ctxData" . "pageClass" "organization settings runners")}}
<div class="org-setting-content">
{{template "shared/actions/runner_create" .}}
</div>
{{template "org/settings/layout_footer" .}}

View file

@ -0,0 +1,5 @@
{{template "org/settings/layout_head" (dict "ctxData" . "pageClass" "organization settings runners")}}
<div class="org-setting-content">
{{template "shared/actions/runner_details" .}}
</div>
{{template "org/settings/layout_footer" .}}

View file

@ -0,0 +1,5 @@
{{template "org/settings/layout_head" (dict "ctxData" . "pageClass" "organization settings runners")}}
<div class="org-setting-content">
{{template "shared/actions/runner_setup" .}}
</div>
{{template "org/settings/layout_footer" .}}

View file

@ -0,0 +1,5 @@
{{template "repo/settings/layout_head" (dict "ctxData" . "pageClass" "repository settings runners")}}
<div class="repo-setting-content">
{{template "shared/actions/runner_create" .}}
</div>
{{template "repo/settings/layout_footer" .}}

View file

@ -0,0 +1,5 @@
{{template "repo/settings/layout_head" (dict "ctxData" . "pageClass" "repository settings runners")}}
<div class="repo-setting-content">
{{template "shared/actions/runner_details" .}}
</div>
{{template "repo/settings/layout_footer" .}}

View file

@ -0,0 +1,5 @@
{{template "repo/settings/layout_head" (dict "ctxData" . "pageClass" "repository settings runners")}}
<div class="repo-setting-content">
{{template "shared/actions/runner_setup" .}}
</div>
{{template "repo/settings/layout_footer" .}}

View file

@ -0,0 +1,22 @@
<div class="runner-container">
<h4 class="ui top attached header">
{{ctx.Locale.Tr "actions.runners.create_runner.title"}}
</h4>
<form class="ui form attached segment" action="{{.Link}}" method="post">
<fieldset>
<legend>{{ctx.Locale.Tr "actions.runners.create_runner.properties_fieldset"}}</legend>
<div class="form-field">
<label for="name">{{ctx.Locale.Tr "actions.runners.create_runner.name_label"}}</label>
<input id="name" name="runner_name" type="text" value="{{.Runner.Name}}"{{if .Err_RunnerName}} class="error"{{end}}>
</div>
<div class="form-field">
<label for="description">{{ctx.Locale.Tr "actions.runners.create_runner.description_label"}}</label>
<textarea id="description" name="runner_description"{{if .Err_RunnerDescription}} class="error"{{end}}>{{.Runner.Description}}</textarea>
</div>
</fieldset>
<div class="form-buttons">
<a class="ui secondary button" href="{{$.RunnersListLink}}">{{ctx.Locale.Tr "actions.runners.create_runner.cancel_button"}}</a>
<button class="ui primary button">{{ctx.Locale.Tr "actions.runners.create_runner.create_button"}}</button>
</div>
</form>
</div>

View file

@ -0,0 +1,132 @@
<div class="runner-container">
<h4 class="ui top attached header">
{{ctx.Locale.Tr "actions.runners.runner_title" .Runner.Name}}
{{if .Runner.Editable $.RunnerOwnerID $.RunnerRepoID}}
<div class="ui right">
<a class="ui primary tiny button" tabindex="0" href="{{$.Link}}/edit">
{{ctx.Locale.Tr "actions.runners.edit_runner_button"}}
</a>
</div>
{{end}}
</h4>
<div class="ui attached segment">
<dl aria-label="Properties of {{.Runner.Name}}">
<div class="item">
<dt>{{ctx.Locale.Tr "actions.runners.uuid"}}</dt>
<dd>
{{.Runner.UUID}}
</dd>
</div>
<div class="item">
<dt>{{ctx.Locale.Tr "actions.runners.owner_type"}}</dt>
<dd>
{{.Runner.BelongsToOwnerType.LocaleString ctx.Locale}}
</dd>
</div>
<div class="item">
<dt>{{ctx.Locale.Tr "actions.runners.labels"}}</dt>
<dd class="tw-flex tw-items-start tw-flex-wrap tw-gap-2">
{{if gt (len .Runner.AgentLabels) 0}}
{{range .Runner.AgentLabels}}
<div class="ui label">{{.}}</div>
{{end}}
{{else}}
&mdash;
{{end}}
</dd>
</div>
<div class="item">
<dt>{{ctx.Locale.Tr "actions.runners.last_online"}}</dt>
<dd>
<div>
{{if .Runner.LastOnline}}
{{DateUtils.TimeSince .Runner.LastOnline}}
{{else}}
{{ctx.Locale.Tr "never"}}
{{end}}
</div>
</dd>
</div>
<div class="item">
<dt>{{ctx.Locale.Tr "actions.runners.status"}}</dt>
<dd class="tw-flex tw-items-center tw-gap-x-2">
{{if .Runner.IsActive}}
<div class="indicator-active">
<div></div>
</div>
{{else if .Runner.IsIdle}}
<div class="indicator-idle">
<div></div>
</div>
{{else}}
<div class="indicator-offline">
<div></div>
</div>
{{end}}
<div>
{{.Runner.StatusLocaleName ctx.Locale}}
</div>
</dd>
</div>
<div class="item">
<dt>{{ctx.Locale.Tr "actions.runners.ephemeral"}}</dt>
<dd>
{{if .Runner.Ephemeral}}
{{ctx.Locale.Tr "actions.runners.ephemeral.yes"}}
{{else}}
{{ctx.Locale.Tr "actions.runners.ephemeral.no"}}
{{end}}
</dd>
</div>
<div class="item">
<dt>{{ctx.Locale.Tr "actions.runners.description"}}</dt>
<dd>
{{if .Runner.Description}}
{{.Runner.Description}}
{{else}}
&mdash;
{{end}}
</dd>
</div>
<p class="tw-mt-8 tw-italic">{{ctx.Locale.Tr "actions.runners.runner_details.labels_note"}}</p>
</dl>
</div>
<h4 class="ui top attached header">
{{ctx.Locale.Tr "actions.runners.task_list"}}
</h4>
<div class="ui attached segment">
<table class="ui very basic striped table unstackable">
<thead>
<tr>
<th>{{ctx.Locale.Tr "actions.runners.task_list.run"}}</th>
<th>{{ctx.Locale.Tr "actions.runners.task_list.status"}}</th>
<th>{{ctx.Locale.Tr "actions.runners.task_list.repository"}}</th>
<th>{{ctx.Locale.Tr "actions.runners.task_list.commit"}}</th>
<th>{{ctx.Locale.Tr "actions.runners.task_list.done_at"}}</th>
</tr>
</thead>
<tbody>
{{range .Tasks}}
<tr>
<td><a href="{{.GetRunLink}}" target="_blank">{{.ID}}</a></td>
<td><span class="ui label task-status-{{.Status.String}}">{{.Status.LocaleString ctx.Locale}}</span></td>
<td><a href="{{.GetRepoLink}}" target="_blank">{{.GetRepoName}}</a></td>
<td>
<strong><a href="{{.GetCommitLink}}" target="_blank">{{ShortSha .CommitSHA}}</a></strong>
</td>
<td>{{if .IsStopped}}
<span>{{DateUtils.TimeSince .Stopped}}</span>
{{else}}-{{end}}</td>
</tr>
{{end}}
{{if not .Tasks}}
<tr>
<td colspan="5">{{ctx.Locale.Tr "actions.runners.task_list.no_tasks"}}</td>
</tr>
{{end}}
</tbody>
</table>
{{template "base/paginate" .}}
</div>
</div>

View file

@ -1,95 +1,34 @@
<div class="runner-container">
<h4 class="ui top attached header">
{{ctx.Locale.Tr "actions.runners.runner_title"}} {{.Runner.ID}} {{.Runner.Name}}
{{ctx.Locale.Tr "actions.runners.edit_runner.title" .Runner.Name}}
</h4>
<div class="ui attached segment">
<form class="ui form" method="post">
{{template "base/disable_form_autofill"}}
<div class="runner-basic-info">
<div class="field tw-inline-block tw-mr-4">
<label>{{ctx.Locale.Tr "actions.runners.status"}}</label>
<span class="ui {{if .Runner.IsOnline}}green{{else}}basic{{end}} label">{{.Runner.StatusLocaleName ctx.Locale}}</span>
<form class="ui form attached segment" action="{{.Link}}" method="post">
<fieldset>
<legend>{{ctx.Locale.Tr "actions.runners.edit_runner.properties_fieldset"}}</legend>
<div class="form-field">
<label for="name">{{ctx.Locale.Tr "actions.runners.edit_runner.name_label"}}</label>
<input id="name" name="runner_name" type="text" value="{{.Runner.Name}}" {{if .Err_RunnerName}}class="error"{{end}}>
</div>
<div class="form-field">
<label for="description">{{ctx.Locale.Tr "actions.runners.edit_runner.description_label"}}</label>
<textarea id="description" name="runner_description" {{if .Err_RunnerDescription}}class="error"{{end}}>{{.Runner.Description}}</textarea>
</div>
</fieldset>
<fieldset>
<legend>{{ctx.Locale.Tr "actions.runners.edit_runner.properties_options"}}</legend>
<div class="form-field tw-flex tw-gap-x-4">
<div class="tw-flex tw-h-6 tw-items-center">
<input type="checkbox" id="regenerate_token" name="regenerate_token">
</div>
<div class="field tw-inline-block tw-mr-4">
<label>{{ctx.Locale.Tr "actions.runners.last_online"}}</label>
<span>{{if .Runner.LastOnline}}{{DateUtils.TimeSince .Runner.LastOnline}}{{else}}{{ctx.Locale.Tr "never"}}{{end}}</span>
</div>
<div class="field tw-inline-block tw-mr-4">
<label>{{ctx.Locale.Tr "actions.runners.labels"}}</label>
<span>
{{range .Runner.AgentLabels}}
<span class="ui label">{{.}}</span>
{{end}}
</span>
</div>
<div class="field tw-inline-block tw-mr-4">
<label>{{ctx.Locale.Tr "actions.runners.owner_type"}}</label>
<span data-tooltip-content="{{.Runner.BelongsToOwnerName}}">{{.Runner.BelongsToOwnerType.LocaleString ctx.Locale}}</span>
<div>
<label for="regenerate_token" class="tw-font-medium tw-m-0">{{ctx.Locale.Tr "actions.runners.edit_runner.regenerate_token_label"}}</label>
<p class="help">{{ctx.Locale.Tr "actions.runners.edit_runner.regenerate_token_help"}}</p>
</div>
</div>
<div class="divider"></div>
<div class="field">
<label for="description">{{ctx.Locale.Tr "actions.runners.description"}}</label>
<input id="description" name="description" value="{{.Runner.Description}}">
</div>
<div class="divider"></div>
<div class="field">
<button class="ui primary button" data-url="{{.Link}}">{{ctx.Locale.Tr "actions.runners.update_runner.button"}}</button>
<button class="ui red button delete-button" data-url="{{.Link}}/delete" data-modal-id="runner-delete-modal">
{{ctx.Locale.Tr "actions.runners.delete_runner.button"}}</button>
</div>
</form>
</div>
<h4 class="ui top attached header">
{{ctx.Locale.Tr "actions.runners.task_list"}}
</h4>
<div class="ui attached segment">
<table class="ui very basic striped table unstackable">
<thead>
<tr>
<th>{{ctx.Locale.Tr "actions.runners.task_list.run"}}</th>
<th>{{ctx.Locale.Tr "actions.runners.task_list.status"}}</th>
<th>{{ctx.Locale.Tr "actions.runners.task_list.repository"}}</th>
<th>{{ctx.Locale.Tr "actions.runners.task_list.commit"}}</th>
<th>{{ctx.Locale.Tr "actions.runners.task_list.done_at"}}</th>
</tr>
</thead>
<tbody>
{{range .Tasks}}
<tr>
<td><a href="{{.GetRunLink}}" target="_blank">{{.ID}}</a></td>
<td><span class="ui label task-status-{{.Status.String}}">{{.Status.LocaleString ctx.Locale}}</span></td>
<td><a href="{{.GetRepoLink}}" target="_blank">{{.GetRepoName}}</a></td>
<td>
<strong><a href="{{.GetCommitLink}}" target="_blank">{{ShortSha .CommitSHA}}</a></strong>
</td>
<td>{{if .IsStopped}}
<span>{{DateUtils.TimeSince .Stopped}}</span>
{{else}}-{{end}}</td>
</tr>
{{end}}
{{if not .Tasks}}
<tr>
<td colspan="5">{{ctx.Locale.Tr "actions.runners.task_list.no_tasks"}}</td>
</tr>
{{end}}
</tbody>
</table>
{{template "base/paginate" .}}
</div>
<div class="ui g-modal-confirm delete modal" id="runner-delete-modal">
<div class="header">
{{svg "octicon-trash"}}
{{ctx.Locale.Tr "actions.runners.delete_runner.header"}}
</fieldset>
<div class="form-buttons">
<a class="ui secondary button" href="{{$.RunnersListLink}}">{{ctx.Locale.Tr "actions.runners.edit_runner.cancel_button"}}</a>
<button class="ui primary button">{{ctx.Locale.Tr "actions.runners.edit_runner.save_button"}}</button>
</div>
<div class="content">
<p>{{ctx.Locale.Tr "actions.runners.delete_runner.notice"}}</p>
</div>
{{template "base/modal_actions_confirm" .}}
</div>
</form>
</div>

View file

@ -4,8 +4,8 @@
{{ctx.Locale.Tr "actions.runners.runner_manage_panel"}} ({{ctx.Locale.Tr "admin.total" .Total}})
<div class="ui right">
<div class="ui top right pointing dropdown">
<button class="ui primary tiny button">
{{ctx.Locale.Tr "actions.runners.new"}}
<button class="ui secondary tiny button">
{{ctx.Locale.Tr "actions.runners.show_registration_token"}}
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
</button>
<div class="menu">
@ -14,7 +14,7 @@
</div>
<div class="divider"></div>
<div class="header">
Registration Token
{{ctx.Locale.Tr "actions.runners.reset_registration_token.token"}}
</div>
<div class="ui input">
<input type="text" value="{{.RegistrationToken}}">
@ -28,7 +28,9 @@
</div>
</div>
</div>
<a class="ui primary tiny button" href="{{$.Link}}/new" tabindex="0">
{{ctx.Locale.Tr "actions.runners.new"}}
</a>
</div>
</h4>
<div class="ui attached segment">
@ -37,59 +39,108 @@
</form>
</div>
<div class="ui attached table segment">
<table class="ui very basic striped table unstackable">
{{if .Runners}}
<table class="ui very basic striped table unstackable runner-list">
<thead>
<tr>
<th data-sortt-asc="online" data-sortt-desc="offline">
{{ctx.Locale.Tr "actions.runners.status"}}
{{SortArrow "online" "offline" .SortType false}}
</th>
<th data-sortt-asc="newest" data-sortt-desc="oldest">
{{ctx.Locale.Tr "actions.runners.id"}}
{{SortArrow "oldest" "newest" .SortType false}}
</th>
<th data-sortt-asc="alphabetically" data-sortt-desc="reversealphabetically">
<th scope="col" data-sortt-asc="alphabetically" data-sortt-desc="reversealphabetically">
{{ctx.Locale.Tr "actions.runners.name"}}
{{SortArrow "alphabetically" "reversealphabetically" .SortType false}}
</th>
<th>{{ctx.Locale.Tr "actions.runners.version"}}</th>
<th>{{ctx.Locale.Tr "actions.runners.owner_type"}}</th>
<th>{{ctx.Locale.Tr "actions.runners.labels"}}</th>
<th>{{ctx.Locale.Tr "actions.runners.last_online"}}</th>
<th>{{ctx.Locale.Tr "edit"}}</th>
<th scope="col">
{{ctx.Locale.Tr "actions.runners.labels"}}
</th>
<th scope="col">
{{ctx.Locale.Tr "actions.runners.owner_type"}}
</th>
<th scope="col" data-sortt-asc="online" data-sortt-desc="offline">
{{ctx.Locale.Tr "actions.runners.status"}}
{{SortArrow "online" "offline" .SortType false}}
</th>
<th scope="col">
<span class="tw-sr-only">{{ctx.Locale.Tr "actions.runners.list_runners.details_column"}}</span>
</th>
<th scope="col">
<span class="tw-sr-only">{{ctx.Locale.Tr "actions.runners.list_runners.edit_column"}}</span>
</th>
<th scope="col">
<span class="tw-sr-only">{{ctx.Locale.Tr "actions.runners.list_runners.delete_column"}}</span>
</th>
</tr>
</thead>
<tbody>
{{if .Runners}}
{{range .Runners}}
<tr>
<td>
<span class="ui {{if .IsOnline}}green{{end}} label">{{.StatusLocaleName ctx.Locale}}</span>
</td>
<td>{{.ID}}</td>
<td><p data-tooltip-content="{{.Description}}">{{.Name}}</p></td>
<td>{{if .Version}}{{.Version}}{{else}}{{ctx.Locale.Tr "unknown"}}{{end}}</td>
<td><span data-tooltip-content="{{.BelongsToOwnerName}}">{{.BelongsToOwnerType.LocaleString ctx.Locale}}</span></td>
<td class="tw-flex tw-flex-wrap tw-gap-2 runner-tags">
{{range .AgentLabels}}<span class="ui label">{{.}}</span>{{end}}
</td>
<td>{{if .LastOnline}}{{DateUtils.TimeSince .LastOnline}}{{else}}{{ctx.Locale.Tr "never"}}{{end}}</td>
<td class="runner-ops">
{{if .Editable $.RunnerOwnerID $.RunnerRepoID}}
<a href="{{$.Link}}/{{.ID}}">{{svg "octicon-pencil"}}</a>
{{range .Runners}}
<tr>
<td>
<div class="tw-font-medium">{{.Name}}</div>
<div class="tw-mt-1">{{.UUID}}</div>
</td>
<td class="tw-flex tw-items-start tw-flex-wrap tw-gap-2">
{{if gt (len .AgentLabels) 0}}
{{range .AgentLabels}}
<div class="ui label special">{{.}}</div>
{{end}}
</td>
</tr>
{{end}}
{{else}}
<tr>
<td class="center aligned" colspan="8">{{ctx.Locale.Tr "actions.runners.none"}}</td>
</tr>
{{else}}
&mdash;
{{end}}
</td>
<td>
{{.BelongsToOwnerType.LocaleString ctx.Locale}}
</td>
<td>
<div class="tw-flex tw-items-center tw-gap-x-2">
{{if .IsActive}}
<div class="indicator-active">
<div></div>
</div>
{{else if .IsIdle}}
<div class="indicator-idle">
<div></div>
</div>
{{else}}
<div class="indicator-offline">
<div></div>
</div>
{{end}}
<div>
{{.StatusLocaleName ctx.Locale}}
</div>
</div>
</td>
<td class="tw-text-right">
<a href="{{$.Link}}/{{.ID}}" class="runner-action-link" tabindex="0" aria-label="{{ctx.Locale.Tr "actions.runners.list_runners.details_button_aria" .Name}}">{{ctx.Locale.Tr "actions.runners.list_runners.details_button"}}</a>
</td>
<td class="tw-text-right">
{{if .Editable $.RunnerOwnerID $.RunnerRepoID}}
<a href="{{$.Link}}/{{.ID}}/edit" class="runner-action-link" tabindex="0" aria-label="{{ctx.Locale.Tr "actions.runners.list_runners.edit_button_aria" .Name}}">{{ctx.Locale.Tr "actions.runners.list_runners.edit_button"}}</a>
{{end}}
</td>
<td class="tw-text-right">
{{if .Editable $.RunnerOwnerID $.RunnerRepoID}}
<button class="delete-button runner-delete-link" aria-label="{{ctx.Locale.Tr "actions.runners.list_runners.delete_button_aria" .Name}}" data-url="{{$.Link}}/{{.ID}}/delete" data-modal-id="runner-delete-modal">{{ctx.Locale.Tr "actions.runners.list_runners.delete_button"}}</button>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="tw-flex tw-p-4">
{{ctx.Locale.Tr "actions.runners.none"}}
</div>
{{end}}
</div>
{{template "base/paginate" .}}
<div class="ui g-modal-confirm delete modal" id="runner-delete-modal">
<div class="header">
{{svg "octicon-trash"}}
{{ctx.Locale.Tr "actions.runners.delete_runner.header"}}
</div>
<div class="content">
<p>{{ctx.Locale.Tr "actions.runners.delete_runner.notice"}}</p>
</div>
{{template "base/modal_actions_confirm" .}}
</div>
</div>

View file

@ -0,0 +1,53 @@
<div class="runner-container">
<h4 class="ui top attached header">
{{ctx.Locale.Tr "actions.runners.runner_setup.title" .Runner.Name}}
<div class="ui right">
<a class="ui secondary tiny button" href="{{.RunnersListLink}}" tabindex="0">
{{ctx.Locale.Tr "actions.runners.runner_setup.list_of_runners_link"}}
</a>
</div>
</h4>
<div class="ui attached segment">
<p>{{ctx.Locale.Tr "actions.runners.runner_setup.last_chance_copying_token"}}</p>
<dl>
<div class="item">
<dt>{{ctx.Locale.Tr "actions.runners.uuid"}}</dt>
<dd>
{{.Runner.UUID}}
<button class="ui basic label button"
aria-label="{{ctx.Locale.Tr "actions.runners.runner_setup.button_copy_uuid_aria"}}"
data-clipboard-text="{{.Runner.UUID}}">
{{svg "octicon-copy" 14}}
</button>
</dd>
</div>
<div class="item">
<dt>{{ctx.Locale.Tr "actions.runners.token"}}</dt>
<dd>
{{.Runner.Token}}
<button class="ui basic label button"
aria-label="{{ctx.Locale.Tr "actions.runners.runner_setup.button_copy_token_aria"}}"
data-clipboard-text="{{.Runner.Token}}">
{{svg "octicon-copy" 14}}
</button>
</dd>
</div>
</dl>
<h5>{{ctx.Locale.Tr "actions.runners.runner_setup.heading_using_configuration"}}</h5>
<pre><code aria-label="{{ctx.Locale.Tr "actions.runners.runner_setup.configuration_snippet_aria"}}">server:
&nbsp;&nbsp;connections:
&nbsp;&nbsp;&nbsp;&nbsp;forgejo:
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;url: {{.AppURL}}
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;uuid: {{.Runner.UUID}}
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;token: {{.Runner.Token}}
</code></pre>
<p>{{ctx.Locale.Tr "actions.runners.runner_setup.instruction_replace_connection_name"}}</p>
<h5>{{ctx.Locale.Tr "actions.runners.runner_setup.heading_using_options"}}</h5>
<pre><code aria-label="{{ctx.Locale.Tr "actions.runners.runner_setup.program_options_snippet_aria"}}">forgejo-runner daemon \
--url {{.AppURL}} \
--uuid {{.Runner.UUID}} \
--token {{.Runner.Token}}
</code></pre>
<p>{{ctx.Locale.Tr "actions.runners.runner_setup.instruction_advanced_configurations"}}</p>
</div>
</div>

View file

@ -0,0 +1,5 @@
{{template "user/settings/layout_head" (dict "ctxData" . "pageClass" "user settings runners")}}
<div class="user-setting-content">
{{template "shared/actions/runner_create" .}}
</div>
{{template "user/settings/layout_footer" .}}

View file

@ -0,0 +1,5 @@
{{template "user/settings/layout_head" (dict "ctxData" . "pageClass" "user settings runners")}}
<div class="user-setting-content">
{{template "shared/actions/runner_details" .}}
</div>
{{template "user/settings/layout_footer" .}}

View file

@ -0,0 +1,5 @@
{{template "user/settings/layout_head" (dict "ctxData" . "pageClass" "user settings runners")}}
<div class="user-setting-content">
{{template "shared/actions/runner_setup" .}}
</div>
{{template "user/settings/layout_footer" .}}

View file

@ -114,6 +114,9 @@ func TestE2e(t *testing.T) {
defer test.MockVariableValue(&setting.Quota.Enabled, true)()
defer test.MockVariableValue(&testE2eWebRoutes, routers.NormalRoutes())()
}
if testname == "runner-management.test.e2e" {
defer unittest.OverrideFixtures("tests/e2e/fixtures/runner-management")()
}
// Default 2 minute timeout
onForgejoRun(t, func(*testing.T, *url.URL) {

View file

@ -0,0 +1,69 @@
- id: 719931
uuid: "8f940b0b-32a2-479a-9d48-06ab8d8a0b90"
name: "runner-1"
version: "dev"
owner_id: 3
repo_id: 0
description: "A superb runner"
agent_labels: ["debian", "gpu"]
deleted: 0
- id: 719932
uuid: "3a20ad8d-d5d6-4b7b-ba55-841ac8264c17"
name: "runner-2"
version: "11.3.1"
owner_id: 2
repo_id: 0
description: "An exclusive runner"
agent_labels: ["docker"]
# token: 9730f9d2c6c731f07582788d1a1fe72a6b999a17
token_hash: ceb24f8ebc06fa5d0bb5a8dcab6f67b9bbc1f0c2f13313aefa0b81fe961ecc1feaad182d2b7f15fe6df0da8c17b12a9b11ef
token_salt: 53GUbQ9nih0vBemnIxV5sU
deleted: 0
- id: 719933
uuid: "11c9a6da-0a92-46ea-a4f1-b6c98f8c781c"
name: "runner-3"
version: "11.3.1"
owner_id: 17
repo_id: 0
description: "Another fine runner"
agent_labels: ["fedora"]
deleted: 0
- id: 719934
uuid: "1ef59b64-93b7-4ad4-ade4-21ca13db49c0"
name: "runner-4"
version: "12.2.0"
owner_id: 0
repo_id: 0
description: "A runner for everyone"
agent_labels: ["docker"]
# token: 1ad04b98cafcc7461ef562b7ff07d1ade169eefb
token_hash: 2aafaefc1690601106c0ccca874140828eb9f98e7719e58f3d399d4c64d11b1a251bd7363ff6fd4d1dbce852558987066334
token_salt: 9--ISaaA6tHZbXMY6dqlqy
deleted: 0
- id: 719935
uuid: "69d29449-1de5-4d17-845d-e3ae11a04a1b"
name: "runner-5"
version: "12.0.0"
owner_id: 1
repo_id: 0
description: ""
agent_labels: ["debian"]
deleted: 0
- id: 719936
uuid: "9da25fbb-89a5-4520-a35a-d55fc94e4b76"
name: "runner-6"
version: "12.1.0"
owner_id: 0
repo_id: 62
description: ""
agent_labels: ["debian"]
deleted: 0
- id: 719937
uuid: "d935307e-1d2d-4b61-8885-bc8a1c52c269"
name: "runner-7"
version: "12.1.0"
owner_id: 4
repo_id: 0
description: ""
agent_labels: ["alpine"]
deleted: 0

View file

@ -0,0 +1,15 @@
- id: 88931
attempt: 1
runner_id: 719934
status: 6 # StatusRunning
repo_id: 32
owner_id: 3 # org3
commit_sha: ed38c5a46c32eb907856178977fef0062cf407c3
- id: 88932
attempt: 1
runner_id: 719934
status: 5 # StatusWaiting
repo_id: 1
owner_id: 2 # user2
commit_sha: 49f55ab99b8ea6a84cae04d265822c93afdc3d50

View file

@ -0,0 +1,706 @@
// @watch start
// modules/actions/**
// routers/web/shared/actions/**
// routers/web/web.go
// services/forms/runner.go
// templates/admin/runners/**
// templates/org/settings/runner*
// templates/repo/settings/runner*
// templates/shared/actions/runner*
// templates/user/settings/runner*
// web_src/css/actions.css
// @watch end
import {test} from './utils_e2e.ts';
import {expect} from '@playwright/test';
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const tokenPattern = /^[0-9a-f]{40}$/;
test.describe('Runners of user2', () => {
test.use({user: 'user2'});
test('usable runners are visible', async ({page}) => {
await page.goto('/user/settings/actions/runners');
await expect(page.getByRole('heading', {name: 'Manage runners'})).toBeVisible();
const runnerContainer = page.locator('.runner-container');
const rows = runnerContainer.getByRole('row');
// We cannot assert the length of the table because it's influenced by global fixtures. It also changes depending on
// the ordering of tests.
await expect(rows.nth(0)).toHaveAccessibleName('Name Labels Type Status Details Edit Delete');
await expect(page.locator('tbody tr:has-text("3a20ad8d-d5d6-4b7b-ba55-841ac8264c17")')).toMatchAriaSnapshot(`
- cell "runner-2 3a20ad8d-d5d6-4b7b-ba55-841ac8264c17"
- cell "docker"
- cell "Individual"
- cell "Offline"
- cell "Show details of runner-2"
- cell "Edit runner-2"
- cell "Delete runner-2"
`);
await expect(page.locator('tbody tr:has-text("1ef59b64-93b7-4ad4-ade4-21ca13db49c0")')).toMatchAriaSnapshot(`
- cell "runner-4 1ef59b64-93b7-4ad4-ade4-21ca13db49c0"
- cell "docker"
- cell "Global"
- cell "Offline"
- cell "Show details of runner-4"
- cell
- cell
`);
await page.getByRole('link', {name: 'Show details of runner-2', exact: true}).click();
await expect(page).toHaveTitle(/^Runner runner-2 .*/);
await page.goto('/user/settings/actions/runners');
await page.getByRole('link', {name: 'Show details of runner-4', exact: true}).click();
await expect(page).toHaveTitle(/^Runner runner-4 .*/);
});
test('runner details with tasks of repositories owned by user', async ({page}) => {
await page.goto('/user/settings/actions/runners');
await page.getByRole('link', {name: 'Show details of runner-4', exact: true}).click();
await expect(page).toHaveTitle(/^Runner runner-4 .*/);
await expect(page.getByRole('heading', {name: 'Runner runner-4'})).toBeVisible();
await expect(page.getByLabel('Properties of runner-4')).toMatchAriaSnapshot(`
- term: UUID
- definition: ${uuidPattern}
- term: Type
- definition: Global
- term: Labels
- definition: docker
- term: Last online time
- definition: Never
- term: Status
- definition: Offline
- term: Ephemeral
- definition: "no"
- term: Description
- definition: A runner for everyone
`);
await expect(page.getByRole('heading', {name: 'Recent tasks on this runner '})).toBeVisible();
const rows = page.getByRole('row');
// Only tasks from repositories owned by user2 should appear.
await expect(rows).toHaveCount(2);
await expect(rows.nth(0)).toHaveAccessibleName('Run Status Repository Commit Done at');
await expect(rows.nth(1)).toHaveAccessibleName('88932 Waiting 49f55ab99b -');
});
test('create new runner', async ({page}) => {
await page.goto('/user/settings/actions/runners');
await expect(page.getByRole('heading', {name: 'Manage runners'})).toBeVisible();
await page.getByRole('link', {name: 'Create new runner'}).click();
await expect(page).toHaveTitle(/^Create new runner .*/);
// Submit an invalid form to test validation.
await page.getByRole('button', {name: 'Create'}).click();
await expect(page.getByRole('paragraph')).toHaveText('Name cannot be empty.');
// Submit a valid form to create a runner.
await page.getByRole('textbox', {name: 'Name*'}).fill('runner-991301');
await page.getByRole('textbox', {name: 'Description'}).fill('Description of runner-991301');
await page.getByRole('button', {name: 'Create'}).click();
// Verify set up instructions.
await expect(page).toHaveTitle(/^Set up runner runner-991301 .*/);
await expect(page.getByRole('heading', {name: 'Set up runner runner-991301'})).toBeVisible();
await page.getByRole('button', {name: 'Copy runner UUID'}).click();
const runnerUUID = await page.evaluate(() => navigator.clipboard.readText());
expect(runnerUUID).toMatch(uuidPattern);
await page.getByRole('button', {name: 'Copy runner token'}).click();
const runnerToken = await page.evaluate(() => navigator.clipboard.readText());
expect(runnerToken).toMatch(tokenPattern);
await expect(page.getByRole('term')).toHaveText(['UUID', 'Token']);
await expect(page.getByRole('definition')).toContainText([runnerUUID, runnerToken]);
await expect(page.getByRole('heading', {name: 'Using the runner configuration file'})).toBeVisible();
await expect(page.getByLabel('Snippet to insert into the runner configuration')).toContainText(`uuid: ${runnerUUID}`);
await expect(page.getByLabel('Snippet to insert into the runner configuration')).toContainText(`token: ${runnerToken}`);
await expect(page.getByRole('heading', {name: 'Using program options'})).toBeVisible();
await expect(page.getByLabel('How to invoke forgejo-runner')).toContainText(`--uuid ${runnerUUID}`);
await expect(page.getByLabel('How to invoke forgejo-runner')).toContainText(`--token ${runnerToken}`);
// Go back to list of runners.
await page.getByRole('link', {name: 'List of runners', exact: true}).click();
await expect(page.locator(`tbody tr:has-text("${runnerUUID}")`)).toMatchAriaSnapshot(`
- cell "runner-991301 ${runnerUUID}"
- cell ""
- cell "Individual"
- cell "Offline"
- cell "Show details of runner-991301"
- cell "Edit runner-991301"
- cell "Delete runner-991301"
`);
});
test('edit runner without changing its token', async ({page}) => {
await page.goto('/user/settings/actions/runners');
// We have to create a new runner because changes to fixtures would affect the remainder of the tests in this file.
await page.getByRole('link', {name: 'Create new runner'}).click();
await page.getByRole('textbox', {name: 'Name*'}).fill('runner-46635');
await page.getByRole('textbox', {name: 'Description'}).fill('Description of runner-46635');
await page.getByRole('button', {name: 'Create'}).click();
// Go back to list of runners.
await page.getByRole('link', {name: 'Runners', exact: true}).click();
// Edit the runner that was just created.
await page.getByRole('link', {name: 'Edit runner-46635'}).click();
await expect(page).toHaveTitle(/^Edit runner runner-46635 .*/);
await expect(page.getByRole('heading', {name: 'Edit runner runner-46635'})).toBeVisible();
// Make the form invalid to test validation.
await page.getByRole('textbox', {name: 'Name*'}).clear();
await page.getByRole('button', {name: 'Save'}).click();
await expect(page.locator('#flash-message')).toHaveText('Name cannot be empty.');
await expect(page.getByRole('textbox', {name: 'Name*'})).toBeEmpty();
await expect(page.getByRole('textbox', {name: 'Description'})).toHaveValue('Description of runner-46635');
// Submit a valid form.
await page.getByRole('textbox', {name: 'Name*'}).fill('runner-46636');
await page.getByRole('textbox', {name: 'Description'}).fill('Description of runner-46636');
await page.getByRole('button', {name: 'Save'}).click();
// Verify that the runner's properties were updated properly.
await expect(page).toHaveTitle(/^Runner runner-46636 .*/);
await expect(page.locator('#flash-message')).toHaveText('Runner edited successfully');
await expect(page.getByRole('heading', {name: 'Runner runner-46636'})).toBeVisible();
await expect(page.getByLabel('Properties of runner-46636')).toMatchAriaSnapshot(`
- term: UUID
- definition: ${uuidPattern}
- term: Type
- definition: Individual
- term: Labels
- definition
- term: Last online time
- definition: Never
- term: Status
- definition: Offline
- term: Ephemeral
- definition: "no"
- term: Description
- definition: Description of runner-46636
`);
});
test('regenerate runner token', async ({page}) => {
await page.goto('/user/settings/actions/runners');
await page.getByRole('link', {name: 'Edit runner-2', exact: true}).click();
await expect(page).toHaveTitle(/^Edit runner runner-2 .*/);
await expect(page.getByRole('heading', {name: 'Edit runner runner-2'})).toBeVisible();
await page.getByRole('checkbox', {name: 'Regenerate token'}).check();
await page.getByRole('button', {name: 'Save'}).click();
// Verify set up instructions.
await expect(page).toHaveTitle(/^Set up runner runner-2 .*/);
await expect(page.getByRole('heading', {name: 'Set up runner runner-2'})).toBeVisible();
await page.getByRole('button', {name: 'Copy runner UUID'}).click();
const runnerUUID = await page.evaluate(() => navigator.clipboard.readText());
expect(runnerUUID).toEqual('3a20ad8d-d5d6-4b7b-ba55-841ac8264c17');
await page.getByRole('button', {name: 'Copy runner token'}).click();
const runnerToken = await page.evaluate(() => navigator.clipboard.readText());
expect(runnerToken).not.toEqual('9730f9d2c6c731f07582788d1a1fe72a6b999a17');
expect(runnerToken).toMatch(tokenPattern);
await expect(page.getByRole('term')).toHaveText(['UUID', 'Token']);
await expect(page.getByRole('definition')).toContainText([runnerUUID, runnerToken]);
await expect(page.getByRole('heading', {name: 'Using the runner configuration file'})).toBeVisible();
await expect(page.getByLabel('Snippet to insert into the runner configuration')).toContainText(`uuid: ${runnerUUID}`);
await expect(page.getByLabel('Snippet to insert into the runner configuration')).toContainText(`token: ${runnerToken}`);
await expect(page.getByRole('heading', {name: 'Using program options'})).toBeVisible();
await expect(page.getByLabel('How to invoke forgejo-runner')).toContainText(`--uuid ${runnerUUID}`);
await expect(page.getByLabel('How to invoke forgejo-runner')).toContainText(`--token ${runnerToken}`);
});
test('delete runner', async ({page}) => {
await page.goto('/user/settings/actions/runners');
// We have to create a new runner because changes to fixtures affect the remainder of the tests in this file.
await page.getByRole('link', {name: 'Create new runner'}).click();
await page.getByRole('textbox', {name: 'Name*'}).fill('runner-660332');
await page.getByRole('textbox', {name: 'Description'}).fill('Description of runner-660332');
await page.getByRole('button', {name: 'Create'}).click();
// Go back to list of runners.
await page.getByRole('link', {name: 'Runners', exact: true}).click();
await expect(page.getByRole('heading', {name: 'Manage runners'})).toBeVisible();
await expect(page.getByRole('document')).toContainText('runner-660332');
// Delete the runner that was just created.
await page.getByRole('button', {name: 'Delete runner-660332'}).click();
// Confirm deletion
await expect(page.getByRole('document')).toContainText('Confirm to delete this runner');
await page.getByRole('button', {name: 'Yes', exact: true}).click();
// Verify that the runner is gone.
await expect(page.locator('#flash-message')).toHaveText('Runner deleted successfully');
await expect(page.getByRole('document')).not.toContainText('runner-660332');
});
});
test.describe('Global runners', () => {
test.use({user: 'user1'});
test('all runners are visible', async ({page}) => {
await page.goto('/admin/actions/runners');
await expect(page.getByRole('heading', {name: 'Manage runners'})).toBeVisible();
const runnerContainer = page.locator('.runner-container');
const rows = runnerContainer.getByRole('row');
// We cannot assert the length of the table because it's influenced by global fixtures. It also changes depending on
// the ordering of tests.
await expect(rows.nth(0)).toHaveAccessibleName('Name Labels Type Status Details Edit Delete');
await expect(page.locator('tbody tr:has-text("8f940b0b-32a2-479a-9d48-06ab8d8a0b90")')).toMatchAriaSnapshot(`
- cell "runner-1 8f940b0b-32a2-479a-9d48-06ab8d8a0b90"
- cell "debian gpu"
- cell "Organization"
- cell "Offline"
- cell "Show details of runner-1"
- cell "Edit runner-1"
- cell "Delete runner-1"
`);
await expect(page.locator('tbody tr:has-text("3a20ad8d-d5d6-4b7b-ba55-841ac8264c17")')).toMatchAriaSnapshot(`
- cell "runner-2 3a20ad8d-d5d6-4b7b-ba55-841ac8264c17"
- cell "docker"
- cell "Individual"
- cell "Offline"
- cell "Show details of runner-2"
- cell "Edit runner-2"
- cell "Delete runner-2"
`);
await expect(page.locator('tbody tr:has-text("11c9a6da-0a92-46ea-a4f1-b6c98f8c781c")')).toMatchAriaSnapshot(`
- cell "runner-3 11c9a6da-0a92-46ea-a4f1-b6c98f8c781c"
- cell "fedora"
- cell "Organization"
- cell "Offline"
- cell "Show details of runner-3"
- cell "Edit runner-3"
- cell "Delete runner-3"
`);
await expect(page.locator('tbody tr:has-text("1ef59b64-93b7-4ad4-ade4-21ca13db49c0")')).toMatchAriaSnapshot(`
- cell "runner-4 1ef59b64-93b7-4ad4-ade4-21ca13db49c0"
- cell "docker"
- cell "Global"
- cell "Offline"
- cell "Show details of runner-4"
- cell "Edit runner-4"
- cell "Delete runner-4"
`);
await expect(page.locator('tbody tr:has-text("69d29449-1de5-4d17-845d-e3ae11a04a1b")')).toMatchAriaSnapshot(`
- cell "runner-5 69d29449-1de5-4d17-845d-e3ae11a04a1b"
- cell "debian"
- cell "Individual"
- cell "Offline"
- cell "Show details of runner-5"
- cell "Edit runner-5"
- cell "Delete runner-5"
`);
await expect(page.locator('tbody tr:has-text("9da25fbb-89a5-4520-a35a-d55fc94e4b76")')).toMatchAriaSnapshot(`
- cell "runner-6 9da25fbb-89a5-4520-a35a-d55fc94e4b76"
- cell "debian"
- cell "Repository"
- cell "Offline"
- cell "Show details of runner-6"
- cell "Edit runner-6"
- cell "Delete runner-6"
`);
await expect(page.locator('tbody tr:has-text("d935307e-1d2d-4b61-8885-bc8a1c52c269")')).toMatchAriaSnapshot(`
- cell "runner-7 d935307e-1d2d-4b61-8885-bc8a1c52c269"
- cell "alpine"
- cell "Individual"
- cell "Offline"
- cell "Show details of runner-7"
- cell "Edit runner-7"
- cell "Delete runner-7"
`);
});
test('runner details with all tasks visible on details page', async ({page}) => {
await page.goto('/admin/actions/runners');
await page.getByRole('link', {name: 'Show details of runner-4', exact: true}).click();
await expect(page).toHaveTitle(/^Runner runner-4 .*/);
await expect(page.getByRole('heading', {name: 'Runner runner-4'})).toBeVisible();
await expect(page.getByLabel('Properties of runner-4')).toMatchAriaSnapshot(`
- term: UUID
- definition: ${uuidPattern}
- term: Type
- definition: Global
- term: Labels
- definition: docker
- term: Last online time
- definition: Never
- term: Status
- definition: Offline
- term: Ephemeral
- definition: "no"
- term: Description
- definition: A runner for everyone
`);
await expect(page.getByRole('heading', {name: 'Recent tasks on this runner '})).toBeVisible();
const rows = page.getByRole('row');
// Only tasks from repositories owned by user2 should appear.
await expect(rows).toHaveCount(3);
await expect(rows.nth(0)).toHaveAccessibleName('Run Status Repository Commit Done at');
await expect(rows.nth(1)).toHaveAccessibleName('88932 Waiting 49f55ab99b -');
await expect(rows.nth(2)).toHaveAccessibleName('88931 Running ed38c5a46c -');
});
test('create new runner', async ({page}) => {
await page.goto('/admin/actions/runners');
await expect(page.getByRole('heading', {name: 'Manage runners'})).toBeVisible();
await page.getByRole('link', {name: 'Create new runner'}).click();
await expect(page).toHaveTitle(/^Create new runner .*/);
// Submit an invalid form to test validation.
await page.getByRole('button', {name: 'Create'}).click();
await expect(page.getByRole('paragraph')).toHaveText('Name cannot be empty.');
// Submit a valid form to create a runner.
await page.getByRole('textbox', {name: 'Name*'}).fill('runner-473465');
await page.getByRole('textbox', {name: 'Description'}).fill('Description of runner-473465');
await page.getByRole('button', {name: 'Create'}).click();
// Verify set up instructions.
await expect(page).toHaveTitle(/^Set up runner runner-473465 .*/);
await expect(page.getByRole('heading', {name: 'Set up runner runner-473465'})).toBeVisible();
await page.getByRole('button', {name: 'Copy runner UUID'}).click();
const runnerUUID = await page.evaluate(() => navigator.clipboard.readText());
expect(runnerUUID).toMatch(uuidPattern);
await page.getByRole('button', {name: 'Copy runner token'}).click();
const runnerToken = await page.evaluate(() => navigator.clipboard.readText());
expect(runnerToken).toMatch(tokenPattern);
await expect(page.getByRole('term')).toHaveText(['UUID', 'Token']);
await expect(page.getByRole('definition')).toContainText([runnerUUID, runnerToken]);
await expect(page.getByRole('heading', {name: 'Using the runner configuration file'})).toBeVisible();
await expect(page.getByLabel('Snippet to insert into the runner configuration')).toContainText(`uuid: ${runnerUUID}`);
await expect(page.getByLabel('Snippet to insert into the runner configuration')).toContainText(`token: ${runnerToken}`);
await expect(page.getByRole('heading', {name: 'Using program options'})).toBeVisible();
await expect(page.getByLabel('How to invoke forgejo-runner')).toContainText(`--uuid ${runnerUUID}`);
await expect(page.getByLabel('How to invoke forgejo-runner')).toContainText(`--token ${runnerToken}`);
// Go back to list of runners.
await page.getByRole('link', {name: 'List of runners', exact: true}).click();
await expect(page.locator(`tbody tr:has-text("${runnerUUID}")`)).toMatchAriaSnapshot(`
- cell "runner-473465 ${runnerUUID}"
- cell ""
- cell "Global"
- cell "Offline"
- cell "Show details of runner-473465"
- cell "Edit runner-473465"
- cell "Delete runner-473465"
`);
});
test('edit runner without changing its token', async ({page}) => {
await page.goto('/admin/actions/runners');
// We have to create a new runner because changes to fixtures would affect the remainder of the tests in this file.
await page.getByRole('link', {name: 'Create new runner'}).click();
await page.getByRole('textbox', {name: 'Name*'}).fill('runner-956857');
await page.getByRole('textbox', {name: 'Description'}).fill('Description of runner-956857');
await page.getByRole('button', {name: 'Create'}).click();
// Go back to list of runners.
await page.getByRole('link', {name: 'Runners', exact: true}).click();
// Edit the runner that was just created.
await page.getByRole('link', {name: 'Edit runner-956857'}).click();
await expect(page).toHaveTitle(/^Edit runner runner-956857 .*/);
await expect(page.getByRole('heading', {name: 'Edit runner runner-956857'})).toBeVisible();
// Make the form invalid to test validation.
await page.getByRole('textbox', {name: 'Name*'}).clear();
await page.getByRole('button', {name: 'Save'}).click();
await expect(page.locator('#flash-message')).toHaveText('Name cannot be empty.');
await expect(page.getByRole('textbox', {name: 'Name*'})).toBeEmpty();
await expect(page.getByRole('textbox', {name: 'Description'})).toHaveValue('Description of runner-956857');
// Submit a valid form.
await page.getByRole('textbox', {name: 'Name*'}).fill('runner-956858');
await page.getByRole('textbox', {name: 'Description'}).fill('Description of runner-956858');
await page.getByRole('button', {name: 'Save'}).click();
// Verify that the runner's properties were updated properly.
await expect(page).toHaveTitle(/^Runner runner-956858 .*/);
await expect(page.locator('#flash-message')).toHaveText('Runner edited successfully');
await expect(page.getByRole('heading', {name: 'Runner runner-956858'})).toBeVisible();
await expect(page.getByLabel('Properties of runner-956858')).toMatchAriaSnapshot(`
- term: UUID
- definition: ${uuidPattern}
- term: Type
- definition: Global
- term: Labels
- definition
- term: Last online time
- definition: Never
- term: Status
- definition: Offline
- term: Ephemeral
- definition: "no"
- term: Description
- definition: Description of runner-956858
`);
});
test('delete runner', async ({page}) => {
await page.goto('/admin/actions/runners');
// We have to create a new runner because changes to fixtures affect the remainder of the tests in this file.
await page.getByRole('link', {name: 'Create new runner'}).click();
await page.getByRole('textbox', {name: 'Name*'}).fill('runner-650332');
await page.getByRole('textbox', {name: 'Description'}).fill('Description of runner-650332');
await page.getByRole('button', {name: 'Create'}).click();
// Go back to list of runners.
await page.getByRole('link', {name: 'Runners', exact: true}).click();
await expect(page.getByRole('heading', {name: 'Manage runners'})).toBeVisible();
await expect(page.getByRole('document')).toContainText('runner-650332');
// Delete the runner that was just created.
await page.getByRole('button', {name: 'Delete runner-650332'}).click();
// Confirm deletion
await expect(page.getByRole('document')).toContainText('Confirm to delete this runner');
await page.getByRole('button', {name: 'Yes', exact: true}).click();
// Verify that the runner is gone.
await expect(page.locator('#flash-message')).toHaveText('Runner deleted successfully');
await expect(page.getByRole('document')).not.toContainText('runner-650332');
});
});
test.describe('Organization runners', () => {
test.use({user: 'user2'});
test('usable runners are visible', async ({page}) => {
await page.goto('/org/org3/settings/actions/runners');
await expect(page.getByRole('heading', {name: 'Manage runners'})).toBeVisible();
const runnerContainer = page.locator('.runner-container');
const rows = runnerContainer.getByRole('row');
// We cannot assert the length of the table because it's influenced by global fixtures. It also changes depending on
// the ordering of tests.
await expect(rows.nth(0)).toHaveAccessibleName('Name Labels Type Status Details Edit Delete');
await expect(page.locator('tbody tr:has-text("8f940b0b-32a2-479a-9d48-06ab8d8a0b90")')).toMatchAriaSnapshot(`
- cell "runner-1 8f940b0b-32a2-479a-9d48-06ab8d8a0b90"
- cell "debian gpu"
- cell "Organization"
- cell "Offline"
- cell "Show details of runner-1"
- cell "Edit runner-1"
- cell "Delete runner-1"
`);
await expect(page.locator('tbody tr:has-text("1ef59b64-93b7-4ad4-ade4-21ca13db49c0")')).toMatchAriaSnapshot(`
- cell "runner-4 1ef59b64-93b7-4ad4-ade4-21ca13db49c0"
- cell "docker"
- cell "Global"
- cell "Offline"
- cell "Show details of runner-4"
- cell
- cell
`);
await expect(page.locator('tbody tr:has-text("3a20ad8d-d5d6-4b7b-ba55-841ac8264c17")')).toBeHidden();
await expect(page.locator('tbody tr:has-text("11c9a6da-0a92-46ea-a4f1-b6c98f8c781c")')).toBeHidden();
await expect(page.locator('tbody tr:has-text("69d29449-1de5-4d17-845d-e3ae11a04a1b")')).toBeHidden();
await expect(page.locator('tbody tr:has-text("9da25fbb-89a5-4520-a35a-d55fc94e4b76")')).toBeHidden();
await expect(page.locator('tbody tr:has-text("d935307e-1d2d-4b61-8885-bc8a1c52c269")')).toBeHidden();
// Verify that details of usable runners are accessible.
await page.getByRole('link', {name: 'Show details of runner-1', exact: true}).click();
await expect(page).toHaveTitle(/^Runner runner-1 .*/);
await page.goto('/org/org3/settings/actions/runners');
await page.getByRole('link', {name: 'Show details of runner-4', exact: true}).click();
await expect(page).toHaveTitle(/^Runner runner-4 .*/);
});
test('runner details with tasks of repositories owned by organization', async ({page}) => {
await page.goto('/org/org3/settings/actions/runners');
await page.getByRole('link', {name: 'Show details of runner-4', exact: true}).click();
await expect(page).toHaveTitle(/^Runner runner-4 .*/);
await expect(page.getByRole('heading', {name: 'Runner runner-4'})).toBeVisible();
await expect(page.getByLabel('Properties of runner-4')).toMatchAriaSnapshot(`
- term: UUID
- definition: ${uuidPattern}
- term: Type
- definition: Global
- term: Labels
- definition: docker
- term: Last online time
- definition: Never
- term: Status
- definition: Offline
- term: Ephemeral
- definition: "no"
- term: Description
- definition: A runner for everyone
`);
await expect(page.getByRole('heading', {name: 'Recent tasks on this runner '})).toBeVisible();
const rows = page.getByRole('row');
// Only tasks from repositories owned by org3 should appear.
await expect(rows).toHaveCount(2);
await expect(rows.nth(0)).toHaveAccessibleName('Run Status Repository Commit Done at');
await expect(rows.nth(1)).toHaveAccessibleName('88931 Running ed38c5a46c -');
});
});
test.describe('Repository runners', () => {
test.use({user: 'user2'});
test('usable runners are visible', async ({page}) => {
await page.goto('/user2/test_workflows/settings/actions/runners');
await expect(page.getByRole('heading', {name: 'Manage runners'})).toBeVisible();
const runnerContainer = page.locator('.runner-container');
const rows = runnerContainer.getByRole('row');
// We cannot assert the length of the table because it's influenced by global fixtures. It also changes depending on
// the ordering of tests.
await expect(rows.nth(0)).toHaveAccessibleName('Name Labels Type Status Details Edit Delete');
await expect(page.locator('tbody tr:has-text("3a20ad8d-d5d6-4b7b-ba55-841ac8264c17")')).toMatchAriaSnapshot(`
- cell "runner-2 3a20ad8d-d5d6-4b7b-ba55-841ac8264c17"
- cell "docker"
- cell "Individual"
- cell "Offline"
- cell "Show details of runner-2"
- cell
- cell
`);
await expect(page.locator('tbody tr:has-text("1ef59b64-93b7-4ad4-ade4-21ca13db49c0")')).toMatchAriaSnapshot(`
- cell "runner-4 1ef59b64-93b7-4ad4-ade4-21ca13db49c0"
- cell "docker"
- cell "Global"
- cell "Offline"
- cell "Show details of runner-4"
- cell
- cell
`);
await expect(page.locator('tbody tr:has-text("9da25fbb-89a5-4520-a35a-d55fc94e4b76")')).toMatchAriaSnapshot(`
- cell "runner-6 9da25fbb-89a5-4520-a35a-d55fc94e4b76"
- cell "debian"
- cell "Repository"
- cell "Offline"
- cell "Show details of runner-6"
- cell "Edit runner-6"
- cell "Delete runner-6"
`);
await expect(page.locator('tbody tr:has-text("11c9a6da-0a92-46ea-a4f1-b6c98f8c781c")')).toBeHidden();
await expect(page.locator('tbody tr:has-text("69d29449-1de5-4d17-845d-e3ae11a04a1b")')).toBeHidden();
await expect(page.locator('tbody tr:has-text("d935307e-1d2d-4b61-8885-bc8a1c52c269")')).toBeHidden();
// Verify that details of usable runners are accessible.
await page.getByRole('link', {name: 'Show details of runner-2', exact: true}).click();
await expect(page).toHaveTitle(/^Runner runner-2 .*/);
await page.goto('/user2/test_workflows/settings/actions/runners');
await page.getByRole('link', {name: 'Show details of runner-4', exact: true}).click();
await expect(page).toHaveTitle(/^Runner runner-4 .*/);
await page.goto('/user2/test_workflows/settings/actions/runners');
await page.getByRole('link', {name: 'Show details of runner-6', exact: true}).click();
await expect(page).toHaveTitle(/^Runner runner-6 .*/);
});
test('runner details with tasks of repository only', async ({page}) => {
await page.goto('/user2/test_workflows/settings/actions/runners');
await page.getByRole('link', {name: 'Show details of runner-4', exact: true}).click();
await expect(page).toHaveTitle(/^Runner runner-4 .*/);
await expect(page.getByRole('heading', {name: 'Runner runner-4'})).toBeVisible();
await expect(page.getByLabel('Properties of runner-4')).toMatchAriaSnapshot(`
- term: UUID
- definition: ${uuidPattern}
- term: Type
- definition: Global
- term: Labels
- definition: docker
- term: Last online time
- definition: Never
- term: Status
- definition: Offline
- term: Ephemeral
- definition: "no"
- term: Description
- definition: A runner for everyone
`);
await expect(page.getByRole('heading', {name: 'Recent tasks on this runner '})).toBeVisible();
const rows = page.getByRole('row');
// Only tasks from this repository should appear.
await expect(rows).toHaveCount(2);
await expect(rows.nth(0)).toHaveAccessibleName('Run Status Repository Commit Done at');
await expect(rows.nth(1)).toHaveAccessibleName('There are no tasks yet.');
});
});

View file

@ -5,6 +5,9 @@
owner_id: 2
repo_id: 0
deleted: 0
# token: 9730f9d2c6c731f07582788d1a1fe72a6b999a17
token_hash: ceb24f8ebc06fa5d0bb5a8dcab6f67b9bbc1f0c2f13313aefa0b81fe961ecc1feaad182d2b7f15fe6df0da8c17b12a9b11ef
token_salt: 53GUbQ9nih0vBemnIxV5sU
-
id: 1002
@ -13,6 +16,9 @@
owner_id: 3
repo_id: 0
deleted: 0
# token: 724e44135713fd2b4d44f3c75955b020bff979ea
token_hash: 3cc123529e77ae1a7e7b77d480a376dddd85f22dae23cf1dcbf552bf1e2afeacce7dee03ddbf4fe55f15c2c31de051069c01
token_salt: afwDV_UMPp_Xm2T8-D6WIt
-
id: 1003
@ -21,6 +27,9 @@
owner_id: 0
repo_id: 1
deleted: 0
# token: 15ef826cbcba0e46c5aa873fc0639bf9a599bc2b
token_hash: 5b4fb4aadf3b7529a6d165f3e785b51e7c2ab812fd427550c438c350662f35ce64b7a5170a60ac49ca322991416d0cf60fdc
token_salt: 98TOI49tvsPkWIgl_CJ14y
-
id: 1004
@ -29,3 +38,6 @@
owner_id: 0
repo_id: 0
deleted: 0
# token: 98a9f9eaa63683f1328e9b80e547f70507f22864
token_hash: 49c9a05828e5d416896420334e30a6c209be407039007ad82728ad3029e4a229373457c68a13a6a06e40998274bbdb4bdd83
token_salt: gqTeYixe5YwmCkc4pwbVAH

View file

@ -6,6 +6,7 @@ package integration
import (
"fmt"
"net/http"
"strings"
"testing"
actions_model "forgejo.org/models/actions"
@ -15,6 +16,7 @@ import (
app_context "forgejo.org/services/context"
"forgejo.org/tests"
"github.com/PuerkitoBio/goquery"
"github.com/stretchr/testify/assert"
)
@ -47,8 +49,11 @@ func TestRunnerModification(t *testing.T) {
sess = adminSess
}
req := NewRequestWithValues(t, "POST", baseURL+fmt.Sprintf("/%d", id), map[string]string{
"description": "New Description",
originalRunner := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: id})
req := NewRequestWithValues(t, "POST", baseURL+fmt.Sprintf("/%d/edit", id), map[string]string{
"runner_name": "New Name",
"runner_description": "New Description",
})
if fail {
sess.MakeRequest(t, req, http.StatusNotFound)
@ -57,6 +62,12 @@ func TestRunnerModification(t *testing.T) {
flashCookie := sess.GetCookie(app_context.CookieNameFlash)
assert.NotNil(t, flashCookie)
assert.Equal(t, "success%3DRunner%2Bedited%2Bsuccessfully", flashCookie.Value)
// Verify that the runner's token isn't changed during a normal update when token regeneration is not
// requested.
updatedRunner := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: id})
assert.Equal(t, originalRunner.TokenHash, updatedRunner.TokenHash, "token was changed unexpectedly")
assert.Equal(t, originalRunner.TokenSalt, updatedRunner.TokenSalt, "token was changed unexpectedly")
}
req = NewRequest(t, "POST", baseURL+fmt.Sprintf("/%d/delete", id))
@ -136,58 +147,132 @@ func TestRunnerVisibility(t *testing.T) {
runnerFive := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: 719935})
runnerSix := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: 719936})
testCases := []struct {
name string
user *user_model.User
url string
expectedRunners []*actions_model.ActionRunner
unexpectedRunners []*actions_model.ActionRunner
}{
{
name: "admin-sees-all",
user: admin,
url: "/admin/actions/runners",
expectedRunners: []*actions_model.ActionRunner{runnerOne, runnerTwo, runnerThree, runnerFour, runnerFive, runnerSix},
unexpectedRunners: []*actions_model.ActionRunner{},
},
{
name: "user-sees-own-and-global",
user: user2,
url: "user/settings/actions/runners",
expectedRunners: []*actions_model.ActionRunner{runnerTwo, runnerFour},
unexpectedRunners: []*actions_model.ActionRunner{runnerOne, runnerThree, runnerFive, runnerSix},
},
{
name: "org-sees-own-and-global",
user: user2,
url: "/org/org3/settings/actions/runners",
expectedRunners: []*actions_model.ActionRunner{runnerOne, runnerFour},
unexpectedRunners: []*actions_model.ActionRunner{runnerTwo, runnerThree, runnerFive, runnerSix},
},
{
name: "user-repo-sees-own-and-users-and-global",
user: user2,
url: "/user2/test_workflows/settings/actions/runners",
expectedRunners: []*actions_model.ActionRunner{runnerTwo, runnerFour, runnerSix},
unexpectedRunners: []*actions_model.ActionRunner{runnerOne, runnerThree, runnerFive},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
session := loginUser(t, testCase.user.Name)
request := NewRequest(t, "GET", testCase.url)
response := session.MakeRequest(t, request, http.StatusOK)
htmlDoc := NewHTMLParser(t, response.Body)
for _, expectedRunner := range testCase.expectedRunners {
selector := fmt.Sprintf("td:contains('%s')", expectedRunner.Name)
assert.Equal(t, 1, htmlDoc.Find(selector).Length(), "runner '%s' could not be found", expectedRunner.Name)
}
for _, unexpectedRunner := range testCase.unexpectedRunners {
selector := fmt.Sprintf("td:contains('%s')", unexpectedRunner.Name)
assert.Zero(t, htmlDoc.Find(selector).Length(), "runner '%s' is unexpectedly present", unexpectedRunner.Name)
}
containsText := func(selection *goquery.Selection, text string) bool {
filtered := selection.FilterFunction(func(i int, s *goquery.Selection) bool {
return strings.Contains(strings.TrimSpace(s.Text()), text)
})
return filtered.Length() == 1
}
t.Run("runner list", func(t *testing.T) {
testCases := []struct {
name string
user *user_model.User
url string
expectedRunners []*actions_model.ActionRunner
unexpectedRunners []*actions_model.ActionRunner
}{
{
name: "Admin sees all",
user: admin,
url: "/admin/actions/runners",
expectedRunners: []*actions_model.ActionRunner{runnerOne, runnerTwo, runnerThree, runnerFour, runnerFive, runnerSix},
unexpectedRunners: []*actions_model.ActionRunner{},
},
{
name: "User sees own and global",
user: user2,
url: "/user/settings/actions/runners",
expectedRunners: []*actions_model.ActionRunner{runnerTwo, runnerFour},
unexpectedRunners: []*actions_model.ActionRunner{runnerOne, runnerThree, runnerFive, runnerSix},
},
{
name: "Org sees own and global",
user: user2,
url: "/org/org3/settings/actions/runners",
expectedRunners: []*actions_model.ActionRunner{runnerOne, runnerFour},
unexpectedRunners: []*actions_model.ActionRunner{runnerTwo, runnerThree, runnerFive, runnerSix},
},
{
name: "User repo sees own and user's and global",
user: user2,
url: "/user2/test_workflows/settings/actions/runners",
expectedRunners: []*actions_model.ActionRunner{runnerTwo, runnerFour, runnerSix},
unexpectedRunners: []*actions_model.ActionRunner{runnerOne, runnerThree, runnerFive},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
session := loginUser(t, testCase.user.Name)
request := NewRequest(t, "GET", testCase.url)
response := session.MakeRequest(t, request, http.StatusOK)
htmlDoc := NewHTMLParser(t, response.Body)
for _, expectedRunner := range testCase.expectedRunners {
selector := fmt.Sprintf("td:contains('%s')", expectedRunner.Name)
assert.Equal(t, 1, htmlDoc.Find(selector).Length(), "runner '%s' could not be found", expectedRunner.Name)
}
for _, unexpectedRunner := range testCase.unexpectedRunners {
selector := fmt.Sprintf("td:contains('%s')", unexpectedRunner.Name)
assert.Zero(t, htmlDoc.Find(selector).Length(), "runner '%s' is unexpectedly present", unexpectedRunner.Name)
}
})
}
})
t.Run("runner details", func(t *testing.T) {
testCases := []struct {
name string
user *user_model.User
runner *actions_model.ActionRunner
accessibleURLs []string
inaccessibleURLs []string
}{
{
name: "Organization runner",
user: user2,
runner: runnerOne,
// Actions are disabled on all repositories of org3. That's why runnerOne isn't accessible in any
// repository.
accessibleURLs: []string{"/org/org3/settings/actions/runners"},
inaccessibleURLs: []string{"/user/settings/actions/runners", "/user2/test_workflows/settings/actions/runners"},
},
{
name: "User runner",
user: user2,
runner: runnerTwo,
accessibleURLs: []string{"/user/settings/actions/runners", "/user2/test_workflows/settings/actions/runners"},
inaccessibleURLs: []string{"/org/org3/settings/actions/runners"},
},
{
name: "Global runner",
user: user2,
runner: runnerFour,
accessibleURLs: []string{
"/user/settings/actions/runners",
"/user2/test_workflows/settings/actions/runners",
"/org/org3/settings/actions/runners",
},
inaccessibleURLs: []string{},
},
{
name: "Repository runner",
user: user2,
runner: runnerSix,
accessibleURLs: []string{"/user2/test_workflows/settings/actions/runners"},
inaccessibleURLs: []string{"/user/settings/actions/runners", "/org/org3/settings/actions/runners"},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
session := loginUser(t, testCase.user.Name)
for _, accessibleURL := range testCase.accessibleURLs {
request := NewRequest(t, "GET", fmt.Sprintf("%s/%d", accessibleURL, testCase.runner.ID))
response := session.MakeRequest(t, request, http.StatusOK)
htmlDoc := NewHTMLParser(t, response.Body)
assert.True(t, containsText(htmlDoc.Find("dd"), testCase.runner.UUID))
}
for _, inaccessibleURL := range testCase.inaccessibleURLs {
request := NewRequest(t, "GET", fmt.Sprintf("%s/%d", inaccessibleURL, testCase.runner.ID))
response := session.MakeRequest(t, request, http.StatusNotFound)
htmlDoc := NewHTMLParser(t, response.Body)
assert.False(t, containsText(htmlDoc.Find("body"), testCase.runner.UUID))
}
})
}
})
}

View file

@ -43,6 +43,100 @@
color: var(--color-white);
}
.runner-container table.runner-list thead th {
font-weight: var(--font-weight-medium);
}
.runner-container table.runner-list th, .runner-container table.runner-list td {
padding-block: 1rem !important;
padding-inline: 0.75rem !important;
vertical-align: top;
}
.indicator-offline, .indicator-idle, .indicator-active {
flex: none;
border-radius: calc(infinity * 1px);
padding: 0.25rem;
}
.indicator-offline {
color: var(--color-indicator-offline);
background-color: var(--color-indicator-offline-20);
}
.indicator-idle {
color: var(--color-indicator-idle);
background-color: var(--color-indicator-idle-20);
}
.indicator-active {
color: var(--color-indicator-active);
background-color: var(--color-indicator-active-20);
}
.runner-container .indicator-offline > div, .runner-container .indicator-idle > div, .runner-container .indicator-active > div {
width: 0.5rem;
height: 0.5rem;
border-radius: calc(infinity * 1px);
background-color: currentcolor;
}
.runner-action-link {
background: transparent;
color: var(--color-primary);
font-weight: var(--font-weight-semibold);
}
.runner-action-link:hover {
text-decoration: underline;
}
.runner-delete-link {
background: transparent;
color: var(--color-red);
font-weight: var(--font-weight-semibold);
}
.runner-delete-link:hover {
text-decoration: underline;
}
.runner-container form .form-field {
margin-block: 1rem !important;
}
.runner-container form .form-buttons {
display: flex;
justify-content: flex-end;
column-gap: 1.5rem;
}
.runner-container pre {
color: var(--color-console-fg);
background-color: var(--color-console-bg);
border-color: var(--color-console-border);
border-width: 1px;
border-radius: 0.5rem;
border-style: dotted;
padding: 1rem;
}
.runner-container dl .item {
display: flex;
padding-block: 0.5rem
}
.runner-container dl dt {
flex: none;
font-weight: var(--font-weight-medium);
width: 12rem;
padding-right: 1.5rem;
}
.runner-container dl dd {
flex-grow: 1;
}
.run-list-item-right {
width: 130px;
display: flex;

View file

@ -196,7 +196,6 @@
--color-orange-badge: #ea580c;
--color-orange-badge-bg: #ea580c22;
--color-orange-badge-hover-bg: #ea580c44;
/* Colors for thin elements: octicons, text, borders */
--thin-lightness: 0.68;
--regular-chroma: 0.19;
@ -281,6 +280,12 @@
/* pattern colors for image diff */
--checkerboard-color-1: #474747;
--checkerboard-color-2: #313131;
--color-indicator-offline: #a1a1aa;
--color-indicator-offline-20: #a1a1aa1a;
--color-indicator-idle: #16a34a;
--color-indicator-idle-20: #16a34a1a;
--color-indicator-active: #2185d0;
--color-indicator-active-20: #2185d033;
accent-color: var(--color-accent);
color-scheme: dark;
}

View file

@ -279,6 +279,12 @@
/* pattern colors for gradient */
--checkerboard-color-1: #ffffff;
--checkerboard-color-2: #e5e5e5;
--color-indicator-offline: #52525b;
--color-indicator-offline-20: #52525b33;
--color-indicator-idle: #16a34a;
--color-indicator-idle-20: #16a34a33;
--color-indicator-active: #2185d0;
--color-indicator-active-20: #2185d033;
accent-color: var(--color-accent);
color-scheme: light;
}

View file

@ -239,6 +239,12 @@
/* pattern colors for image diff */
--checkerboard-color-1: #313131;
--checkerboard-color-2: #212121;
--color-indicator-offline: #a1a1aa;
--color-indicator-offline-20: #a1a1aa1a;
--color-indicator-idle: #16a34a;
--color-indicator-idle-20: #16a34a1a;
--color-indicator-active: #2185d0;
--color-indicator-active-20: #2185d033;
accent-color: var(--color-accent);
color-scheme: dark;
}

View file

@ -239,6 +239,12 @@
/* pattern colors for gradient */
--checkerboard-color-1: #ffffff;
--checkerboard-color-2: #e5e5e5;
--color-indicator-offline: #52525b;
--color-indicator-offline-20: #52525b1a;
--color-indicator-idle: #16a34a;
--color-indicator-idle-20: #16a34a1a;
--color-indicator-active: #2185d0;
--color-indicator-active-20: #2185d033;
accent-color: var(--color-accent);
color-scheme: light;
}