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; }