From 29635728457de58fab85e0dc7f58e399870c1b4e Mon Sep 17 00:00:00 2001 From: Andreas Ahlenstorf Date: Thu, 12 Mar 2026 02:14:45 +0100 Subject: [PATCH] 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/.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 Co-authored-by: Andreas Ahlenstorf Co-committed-by: Andreas Ahlenstorf --- .../action_runner.yml | 36 + models/actions/runner.go | 37 + models/actions/runner_test.go | 139 ++++ options/locale_next/locale_en-US.json | 60 +- routers/web/repo/setting/runners.go | 205 +++-- routers/web/shared/actions/runners.go | 178 ++++- routers/web/shared/actions/runners_test.go | 9 +- routers/web/web.go | 7 +- services/forms/runner.go | 22 +- templates/admin/runners/create.tmpl | 5 + templates/admin/runners/details.tmpl | 5 + templates/admin/runners/edit.tmpl | 6 +- templates/admin/runners/setup.tmpl | 5 + templates/org/settings/runners_create.tmpl | 5 + templates/org/settings/runners_details.tmpl | 5 + templates/org/settings/runners_setup.tmpl | 5 + templates/repo/settings/runner_create.tmpl | 5 + templates/repo/settings/runner_details.tmpl | 5 + templates/repo/settings/runner_setup.tmpl | 5 + templates/shared/actions/runner_create.tmpl | 22 + templates/shared/actions/runner_details.tmpl | 132 ++++ templates/shared/actions/runner_edit.tmpl | 113 +-- templates/shared/actions/runner_list.tmpl | 137 ++-- templates/shared/actions/runner_setup.tmpl | 53 ++ templates/user/settings/runner_create.tmpl | 5 + templates/user/settings/runner_details.tmpl | 5 + templates/user/settings/runner_setup.tmpl | 5 + tests/e2e/e2e_test.go | 3 + .../runner-management/action_runner.yml | 69 ++ .../runner-management/action_task.yml | 15 + tests/e2e/runner-management.test.e2e.ts | 706 ++++++++++++++++++ .../TestRunnerModification/action_runner.yml | 12 + tests/integration/runner_test.go | 193 +++-- web_src/css/actions.css | 94 +++ web_src/css/themes/theme-forgejo-dark.css | 7 +- web_src/css/themes/theme-forgejo-light.css | 6 + web_src/css/themes/theme-gitea-dark.css | 6 + web_src/css/themes/theme-gitea-light.css | 6 + 38 files changed, 2030 insertions(+), 303 deletions(-) create mode 100644 models/actions/TestRunner_GetAvailableRunnerByID/action_runner.yml create mode 100644 templates/admin/runners/create.tmpl create mode 100644 templates/admin/runners/details.tmpl create mode 100644 templates/admin/runners/setup.tmpl create mode 100644 templates/org/settings/runners_create.tmpl create mode 100644 templates/org/settings/runners_details.tmpl create mode 100644 templates/org/settings/runners_setup.tmpl create mode 100644 templates/repo/settings/runner_create.tmpl create mode 100644 templates/repo/settings/runner_details.tmpl create mode 100644 templates/repo/settings/runner_setup.tmpl create mode 100644 templates/shared/actions/runner_create.tmpl create mode 100644 templates/shared/actions/runner_details.tmpl create mode 100644 templates/shared/actions/runner_setup.tmpl create mode 100644 templates/user/settings/runner_create.tmpl create mode 100644 templates/user/settings/runner_details.tmpl create mode 100644 templates/user/settings/runner_setup.tmpl create mode 100644 tests/e2e/fixtures/runner-management/action_runner.yml create mode 100644 tests/e2e/fixtures/runner-management/action_task.yml create mode 100644 tests/e2e/runner-management.test.e2e.ts diff --git a/models/actions/TestRunner_GetAvailableRunnerByID/action_runner.yml b/models/actions/TestRunner_GetAvailableRunnerByID/action_runner.yml new file mode 100644 index 0000000000..1b8198236e --- /dev/null +++ b/models/actions/TestRunner_GetAvailableRunnerByID/action_runner.yml @@ -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 diff --git a/models/actions/runner.go b/models/actions/runner.go index 3761cb6ccf..ea2e811f94 100644 --- a/models/actions/runner.go +++ b/models/actions/runner.go @@ -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) diff --git a/models/actions/runner_test.go b/models/actions/runner_test.go index f6d6bd5d23..d279c776c6 100644 --- a/models/actions/runner_test.go +++ b/models/actions/runner_test.go @@ -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) + } + }) + } +} diff --git a/options/locale_next/locale_en-US.json b/options/locale_next/locale_en-US.json index f87a3a6fdb..d358fcc424 100644 --- a/options/locale_next/locale_en-US.json +++ b/options/locale_next/locale_en-US.json @@ -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 (forgejo 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 documentation.", "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." } diff --git a/routers/web/repo/setting/runners.go b/routers/web/repo/setting/runners.go index 32c8667825..ab3ec32c4a 100644 --- a/routers/web/repo/setting/runners.go +++ b/routers/web/repo/setting/runners.go @@ -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) { diff --git a/routers/web/shared/actions/runners.go b/routers/web/shared/actions/runners.go index 9d0ad3b0cd..ab52faf9f5 100644 --- a/routers/web/shared/actions/runners.go +++ b/routers/web/shared/actions/runners.go @@ -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) diff --git a/routers/web/shared/actions/runners_test.go b/routers/web/shared/actions/runners_test.go index ad75d34ee6..9426bbdf08 100644 --- a/routers/web/shared/actions/runners_test.go +++ b/routers/web/shared/actions/runners_test.go @@ -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) }) diff --git a/routers/web/web.go b/routers/web/web.go index 6cddc1d0b0..ee3a515dff 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -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) diff --git a/services/forms/runner.go b/services/forms/runner.go index fcf6c5a694..319fffcee1 100644 --- a/services/forms/runner.go +++ b/services/forms/runner.go @@ -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) diff --git a/templates/admin/runners/create.tmpl b/templates/admin/runners/create.tmpl new file mode 100644 index 0000000000..5d3bc739ac --- /dev/null +++ b/templates/admin/runners/create.tmpl @@ -0,0 +1,5 @@ +{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin runners")}} +
+ {{template "shared/actions/runner_create" .}} +
+{{template "admin/layout_footer" .}} diff --git a/templates/admin/runners/details.tmpl b/templates/admin/runners/details.tmpl new file mode 100644 index 0000000000..47eb552952 --- /dev/null +++ b/templates/admin/runners/details.tmpl @@ -0,0 +1,5 @@ +{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin runners")}} +
+ {{template "shared/actions/runner_details" .}} +
+{{template "admin/layout_footer" .}} diff --git a/templates/admin/runners/edit.tmpl b/templates/admin/runners/edit.tmpl index 1165c84b79..6a4b696696 100644 --- a/templates/admin/runners/edit.tmpl +++ b/templates/admin/runners/edit.tmpl @@ -1,5 +1,5 @@ {{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin runners")}} -
- {{template "shared/actions/runner_edit" .}} -
+
+ {{template "shared/actions/runner_edit" .}} +
{{template "admin/layout_footer" .}} diff --git a/templates/admin/runners/setup.tmpl b/templates/admin/runners/setup.tmpl new file mode 100644 index 0000000000..6fa9f69a96 --- /dev/null +++ b/templates/admin/runners/setup.tmpl @@ -0,0 +1,5 @@ +{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin runners")}} +
+ {{template "shared/actions/runner_setup" .}} +
+{{template "admin/layout_footer" .}} diff --git a/templates/org/settings/runners_create.tmpl b/templates/org/settings/runners_create.tmpl new file mode 100644 index 0000000000..422d978811 --- /dev/null +++ b/templates/org/settings/runners_create.tmpl @@ -0,0 +1,5 @@ +{{template "org/settings/layout_head" (dict "ctxData" . "pageClass" "organization settings runners")}} +
+ {{template "shared/actions/runner_create" .}} +
+{{template "org/settings/layout_footer" .}} diff --git a/templates/org/settings/runners_details.tmpl b/templates/org/settings/runners_details.tmpl new file mode 100644 index 0000000000..367a4cd019 --- /dev/null +++ b/templates/org/settings/runners_details.tmpl @@ -0,0 +1,5 @@ +{{template "org/settings/layout_head" (dict "ctxData" . "pageClass" "organization settings runners")}} +
+ {{template "shared/actions/runner_details" .}} +
+{{template "org/settings/layout_footer" .}} diff --git a/templates/org/settings/runners_setup.tmpl b/templates/org/settings/runners_setup.tmpl new file mode 100644 index 0000000000..da377f3776 --- /dev/null +++ b/templates/org/settings/runners_setup.tmpl @@ -0,0 +1,5 @@ +{{template "org/settings/layout_head" (dict "ctxData" . "pageClass" "organization settings runners")}} +
+ {{template "shared/actions/runner_setup" .}} +
+{{template "org/settings/layout_footer" .}} diff --git a/templates/repo/settings/runner_create.tmpl b/templates/repo/settings/runner_create.tmpl new file mode 100644 index 0000000000..cf29555970 --- /dev/null +++ b/templates/repo/settings/runner_create.tmpl @@ -0,0 +1,5 @@ +{{template "repo/settings/layout_head" (dict "ctxData" . "pageClass" "repository settings runners")}} +
+ {{template "shared/actions/runner_create" .}} +
+{{template "repo/settings/layout_footer" .}} diff --git a/templates/repo/settings/runner_details.tmpl b/templates/repo/settings/runner_details.tmpl new file mode 100644 index 0000000000..b79080c990 --- /dev/null +++ b/templates/repo/settings/runner_details.tmpl @@ -0,0 +1,5 @@ +{{template "repo/settings/layout_head" (dict "ctxData" . "pageClass" "repository settings runners")}} +
+ {{template "shared/actions/runner_details" .}} +
+{{template "repo/settings/layout_footer" .}} diff --git a/templates/repo/settings/runner_setup.tmpl b/templates/repo/settings/runner_setup.tmpl new file mode 100644 index 0000000000..65e6f74e3f --- /dev/null +++ b/templates/repo/settings/runner_setup.tmpl @@ -0,0 +1,5 @@ +{{template "repo/settings/layout_head" (dict "ctxData" . "pageClass" "repository settings runners")}} +
+ {{template "shared/actions/runner_setup" .}} +
+{{template "repo/settings/layout_footer" .}} diff --git a/templates/shared/actions/runner_create.tmpl b/templates/shared/actions/runner_create.tmpl new file mode 100644 index 0000000000..9ee7bedde8 --- /dev/null +++ b/templates/shared/actions/runner_create.tmpl @@ -0,0 +1,22 @@ +
+

+ {{ctx.Locale.Tr "actions.runners.create_runner.title"}} +

+
+
+ {{ctx.Locale.Tr "actions.runners.create_runner.properties_fieldset"}} +
+ + +
+
+ + +
+
+
+ {{ctx.Locale.Tr "actions.runners.create_runner.cancel_button"}} + +
+
+
diff --git a/templates/shared/actions/runner_details.tmpl b/templates/shared/actions/runner_details.tmpl new file mode 100644 index 0000000000..ee073c6098 --- /dev/null +++ b/templates/shared/actions/runner_details.tmpl @@ -0,0 +1,132 @@ +
+

+ {{ctx.Locale.Tr "actions.runners.runner_title" .Runner.Name}} + {{if .Runner.Editable $.RunnerOwnerID $.RunnerRepoID}} + + {{end}} +

+
+
+
+
{{ctx.Locale.Tr "actions.runners.uuid"}}
+
+ {{.Runner.UUID}} +
+
+
+
{{ctx.Locale.Tr "actions.runners.owner_type"}}
+
+ {{.Runner.BelongsToOwnerType.LocaleString ctx.Locale}} +
+
+
+
{{ctx.Locale.Tr "actions.runners.labels"}}
+
+ {{if gt (len .Runner.AgentLabels) 0}} + {{range .Runner.AgentLabels}} +
{{.}}
+ {{end}} + {{else}} + — + {{end}} +
+
+
+
{{ctx.Locale.Tr "actions.runners.last_online"}}
+
+
+ {{if .Runner.LastOnline}} + {{DateUtils.TimeSince .Runner.LastOnline}} + {{else}} + {{ctx.Locale.Tr "never"}} + {{end}} +
+
+
+
+
{{ctx.Locale.Tr "actions.runners.status"}}
+
+ {{if .Runner.IsActive}} +
+
+
+ {{else if .Runner.IsIdle}} +
+
+
+ {{else}} +
+
+
+ {{end}} +
+ {{.Runner.StatusLocaleName ctx.Locale}} +
+
+
+
+
{{ctx.Locale.Tr "actions.runners.ephemeral"}}
+
+ {{if .Runner.Ephemeral}} + {{ctx.Locale.Tr "actions.runners.ephemeral.yes"}} + {{else}} + {{ctx.Locale.Tr "actions.runners.ephemeral.no"}} + {{end}} +
+
+
+
{{ctx.Locale.Tr "actions.runners.description"}}
+
+ {{if .Runner.Description}} + {{.Runner.Description}} + {{else}} + — + {{end}} +
+
+

{{ctx.Locale.Tr "actions.runners.runner_details.labels_note"}}

+
+
+ +

+ {{ctx.Locale.Tr "actions.runners.task_list"}} +

+
+ + + + + + + + + + + + {{range .Tasks}} + + + + + + + + {{end}} + {{if not .Tasks}} + + + + {{end}} + +
{{ctx.Locale.Tr "actions.runners.task_list.run"}}{{ctx.Locale.Tr "actions.runners.task_list.status"}}{{ctx.Locale.Tr "actions.runners.task_list.repository"}}{{ctx.Locale.Tr "actions.runners.task_list.commit"}}{{ctx.Locale.Tr "actions.runners.task_list.done_at"}}
{{.ID}}{{.Status.LocaleString ctx.Locale}}{{.GetRepoName}} + {{ShortSha .CommitSHA}} + {{if .IsStopped}} + {{DateUtils.TimeSince .Stopped}} + {{else}}-{{end}}
{{ctx.Locale.Tr "actions.runners.task_list.no_tasks"}}
+ {{template "base/paginate" .}} +
+
diff --git a/templates/shared/actions/runner_edit.tmpl b/templates/shared/actions/runner_edit.tmpl index 66e419c520..749a85a6a3 100644 --- a/templates/shared/actions/runner_edit.tmpl +++ b/templates/shared/actions/runner_edit.tmpl @@ -1,95 +1,34 @@

- {{ctx.Locale.Tr "actions.runners.runner_title"}} {{.Runner.ID}} {{.Runner.Name}} + {{ctx.Locale.Tr "actions.runners.edit_runner.title" .Runner.Name}}

-
-
- {{template "base/disable_form_autofill"}} -
-
- - {{.Runner.StatusLocaleName ctx.Locale}} + +
+ {{ctx.Locale.Tr "actions.runners.edit_runner.properties_fieldset"}} +
+ + +
+
+ + +
+
+
+ {{ctx.Locale.Tr "actions.runners.edit_runner.properties_options"}} +
+
+
-
- - {{if .Runner.LastOnline}}{{DateUtils.TimeSince .Runner.LastOnline}}{{else}}{{ctx.Locale.Tr "never"}}{{end}} -
-
- - - {{range .Runner.AgentLabels}} - {{.}} - {{end}} - -
-
- - {{.Runner.BelongsToOwnerType.LocaleString ctx.Locale}} +
+ +

{{ctx.Locale.Tr "actions.runners.edit_runner.regenerate_token_help"}}

- -
- -
- - -
- -
- -
- - -
- -
- -

- {{ctx.Locale.Tr "actions.runners.task_list"}} -

-
- - - - - - - - - - - - {{range .Tasks}} - - - - - - - - {{end}} - {{if not .Tasks}} - - - - {{end}} - -
{{ctx.Locale.Tr "actions.runners.task_list.run"}}{{ctx.Locale.Tr "actions.runners.task_list.status"}}{{ctx.Locale.Tr "actions.runners.task_list.repository"}}{{ctx.Locale.Tr "actions.runners.task_list.commit"}}{{ctx.Locale.Tr "actions.runners.task_list.done_at"}}
{{.ID}}{{.Status.LocaleString ctx.Locale}}{{.GetRepoName}} - {{ShortSha .CommitSHA}} - {{if .IsStopped}} - {{DateUtils.TimeSince .Stopped}} - {{else}}-{{end}}
{{ctx.Locale.Tr "actions.runners.task_list.no_tasks"}}
- {{template "base/paginate" .}} -
-
+
+ {{ctx.Locale.Tr "actions.runners.edit_runner.cancel_button"}} +
-
-

{{ctx.Locale.Tr "actions.runners.delete_runner.notice"}}

-
- {{template "base/modal_actions_confirm" .}} -
+
diff --git a/templates/shared/actions/runner_list.tmpl b/templates/shared/actions/runner_list.tmpl index ea28a7692f..e40aaaff3e 100644 --- a/templates/shared/actions/runner_list.tmpl +++ b/templates/shared/actions/runner_list.tmpl @@ -4,8 +4,8 @@ {{ctx.Locale.Tr "actions.runners.runner_manage_panel"}} ({{ctx.Locale.Tr "admin.total" .Total}})
- + + {{ctx.Locale.Tr "actions.runners.new"}} +
@@ -37,59 +39,108 @@
- + {{if .Runners}} +
- - - - - - - - + + + + + + - {{if .Runners}} - {{range .Runners}} - - - - - - - - - + + - - {{end}} - {{else}} - - - + {{else}} + — + {{end}} + + + + + + + {{end}}
- {{ctx.Locale.Tr "actions.runners.status"}} - {{SortArrow "online" "offline" .SortType false}} - - {{ctx.Locale.Tr "actions.runners.id"}} - {{SortArrow "oldest" "newest" .SortType false}} - + {{ctx.Locale.Tr "actions.runners.name"}} {{SortArrow "alphabetically" "reversealphabetically" .SortType false}} {{ctx.Locale.Tr "actions.runners.version"}}{{ctx.Locale.Tr "actions.runners.owner_type"}}{{ctx.Locale.Tr "actions.runners.labels"}}{{ctx.Locale.Tr "actions.runners.last_online"}}{{ctx.Locale.Tr "edit"}} + {{ctx.Locale.Tr "actions.runners.labels"}} + + {{ctx.Locale.Tr "actions.runners.owner_type"}} + + {{ctx.Locale.Tr "actions.runners.status"}} + {{SortArrow "online" "offline" .SortType false}} + + {{ctx.Locale.Tr "actions.runners.list_runners.details_column"}} + + {{ctx.Locale.Tr "actions.runners.list_runners.edit_column"}} + + {{ctx.Locale.Tr "actions.runners.list_runners.delete_column"}} +
- {{.StatusLocaleName ctx.Locale}} - {{.ID}}

{{.Name}}

{{if .Version}}{{.Version}}{{else}}{{ctx.Locale.Tr "unknown"}}{{end}}{{.BelongsToOwnerType.LocaleString ctx.Locale}} - {{range .AgentLabels}}{{.}}{{end}} - {{if .LastOnline}}{{DateUtils.TimeSince .LastOnline}}{{else}}{{ctx.Locale.Tr "never"}}{{end}} - {{if .Editable $.RunnerOwnerID $.RunnerRepoID}} - {{svg "octicon-pencil"}} + {{range .Runners}} +
+
{{.Name}}
+
{{.UUID}}
+
+ {{if gt (len .AgentLabels) 0}} + {{range .AgentLabels}} +
{{.}}
{{end}} -
{{ctx.Locale.Tr "actions.runners.none"}}
+ {{.BelongsToOwnerType.LocaleString ctx.Locale}} + +
+ {{if .IsActive}} +
+
+
+ {{else if .IsIdle}} +
+
+
+ {{else}} +
+
+
+ {{end}} +
+ {{.StatusLocaleName ctx.Locale}} +
+
+
+ {{ctx.Locale.Tr "actions.runners.list_runners.details_button"}} + + {{if .Editable $.RunnerOwnerID $.RunnerRepoID}} + {{ctx.Locale.Tr "actions.runners.list_runners.edit_button"}} + {{end}} + + {{if .Editable $.RunnerOwnerID $.RunnerRepoID}} + + {{end}} +
+ {{else}} +
+ {{ctx.Locale.Tr "actions.runners.none"}} +
+ {{end}}
{{template "base/paginate" .}} +
diff --git a/templates/shared/actions/runner_setup.tmpl b/templates/shared/actions/runner_setup.tmpl new file mode 100644 index 0000000000..b18c995b3b --- /dev/null +++ b/templates/shared/actions/runner_setup.tmpl @@ -0,0 +1,53 @@ +
+

+ {{ctx.Locale.Tr "actions.runners.runner_setup.title" .Runner.Name}} + +

+
+

{{ctx.Locale.Tr "actions.runners.runner_setup.last_chance_copying_token"}}

+
+
+
{{ctx.Locale.Tr "actions.runners.uuid"}}
+
+ {{.Runner.UUID}} + +
+
+
+
{{ctx.Locale.Tr "actions.runners.token"}}
+
+ {{.Runner.Token}} + +
+
+
+
{{ctx.Locale.Tr "actions.runners.runner_setup.heading_using_configuration"}}
+
server:
+  connections:
+    forgejo:
+      url: {{.AppURL}}
+      uuid: {{.Runner.UUID}}
+      token: {{.Runner.Token}}
+
+

{{ctx.Locale.Tr "actions.runners.runner_setup.instruction_replace_connection_name"}}

+
{{ctx.Locale.Tr "actions.runners.runner_setup.heading_using_options"}}
+
forgejo-runner daemon \
+	--url {{.AppURL}} \
+	--uuid {{.Runner.UUID}} \
+	--token {{.Runner.Token}}
+
+

{{ctx.Locale.Tr "actions.runners.runner_setup.instruction_advanced_configurations"}}

+
+
diff --git a/templates/user/settings/runner_create.tmpl b/templates/user/settings/runner_create.tmpl new file mode 100644 index 0000000000..1c6371d7f4 --- /dev/null +++ b/templates/user/settings/runner_create.tmpl @@ -0,0 +1,5 @@ +{{template "user/settings/layout_head" (dict "ctxData" . "pageClass" "user settings runners")}} +
+ {{template "shared/actions/runner_create" .}} +
+{{template "user/settings/layout_footer" .}} diff --git a/templates/user/settings/runner_details.tmpl b/templates/user/settings/runner_details.tmpl new file mode 100644 index 0000000000..ed159da955 --- /dev/null +++ b/templates/user/settings/runner_details.tmpl @@ -0,0 +1,5 @@ +{{template "user/settings/layout_head" (dict "ctxData" . "pageClass" "user settings runners")}} +
+ {{template "shared/actions/runner_details" .}} +
+{{template "user/settings/layout_footer" .}} diff --git a/templates/user/settings/runner_setup.tmpl b/templates/user/settings/runner_setup.tmpl new file mode 100644 index 0000000000..ad6998d641 --- /dev/null +++ b/templates/user/settings/runner_setup.tmpl @@ -0,0 +1,5 @@ +{{template "user/settings/layout_head" (dict "ctxData" . "pageClass" "user settings runners")}} +
+ {{template "shared/actions/runner_setup" .}} +
+{{template "user/settings/layout_footer" .}} diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go index 34bedf9dc8..9168cb8b51 100644 --- a/tests/e2e/e2e_test.go +++ b/tests/e2e/e2e_test.go @@ -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) { diff --git a/tests/e2e/fixtures/runner-management/action_runner.yml b/tests/e2e/fixtures/runner-management/action_runner.yml new file mode 100644 index 0000000000..54f45da349 --- /dev/null +++ b/tests/e2e/fixtures/runner-management/action_runner.yml @@ -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 diff --git a/tests/e2e/fixtures/runner-management/action_task.yml b/tests/e2e/fixtures/runner-management/action_task.yml new file mode 100644 index 0000000000..892e22e6cc --- /dev/null +++ b/tests/e2e/fixtures/runner-management/action_task.yml @@ -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 diff --git a/tests/e2e/runner-management.test.e2e.ts b/tests/e2e/runner-management.test.e2e.ts new file mode 100644 index 0000000000..04e3e1d168 --- /dev/null +++ b/tests/e2e/runner-management.test.e2e.ts @@ -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.'); + }); +}); diff --git a/tests/integration/fixtures/TestRunnerModification/action_runner.yml b/tests/integration/fixtures/TestRunnerModification/action_runner.yml index 95599b19bd..29a51caa12 100644 --- a/tests/integration/fixtures/TestRunnerModification/action_runner.yml +++ b/tests/integration/fixtures/TestRunnerModification/action_runner.yml @@ -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 diff --git a/tests/integration/runner_test.go b/tests/integration/runner_test.go index df88458acd..e0afc6784c 100644 --- a/tests/integration/runner_test.go +++ b/tests/integration/runner_test.go @@ -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)) + } + }) + } + }) } diff --git a/web_src/css/actions.css b/web_src/css/actions.css index a9ff222e3c..03e548be4a 100644 --- a/web_src/css/actions.css +++ b/web_src/css/actions.css @@ -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; diff --git a/web_src/css/themes/theme-forgejo-dark.css b/web_src/css/themes/theme-forgejo-dark.css index a89666725b..974f920158 100644 --- a/web_src/css/themes/theme-forgejo-dark.css +++ b/web_src/css/themes/theme-forgejo-dark.css @@ -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; } diff --git a/web_src/css/themes/theme-forgejo-light.css b/web_src/css/themes/theme-forgejo-light.css index f0d7413072..da89f0db73 100644 --- a/web_src/css/themes/theme-forgejo-light.css +++ b/web_src/css/themes/theme-forgejo-light.css @@ -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; } diff --git a/web_src/css/themes/theme-gitea-dark.css b/web_src/css/themes/theme-gitea-dark.css index 983d1ce6e2..967b046899 100644 --- a/web_src/css/themes/theme-gitea-dark.css +++ b/web_src/css/themes/theme-gitea-dark.css @@ -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; } diff --git a/web_src/css/themes/theme-gitea-light.css b/web_src/css/themes/theme-gitea-light.css index 8d88f5e204..91b0c52703 100644 --- a/web_src/css/themes/theme-gitea-light.css +++ b/web_src/css/themes/theme-gitea-light.css @@ -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; }