diff --git a/modules/setting/setting.go b/modules/setting/setting.go index 9644d9b83b..31de96c5b0 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -235,3 +235,9 @@ func LoadSettingsForInstall() { loadServiceFrom(CfgProvider) loadMailerFrom(CfgProvider) } + +func PanicInDevOrTesting(msg string, a ...any) { + if !IsProd || IsInTesting { + panic(fmt.Sprintf(msg, a...)) + } +} diff --git a/modules/structs/repo_actions.go b/modules/structs/repo_actions.go index b13f344738..eada2db09f 100644 --- a/modules/structs/repo_actions.go +++ b/modules/structs/repo_actions.go @@ -32,3 +32,27 @@ type ActionTaskResponse struct { Entries []*ActionTask `json:"workflow_runs"` TotalCount int64 `json:"total_count"` } + +// ActionRunnerLabel represents a Runner Label +type ActionRunnerLabel struct { + ID int64 `json:"id"` + Name string `json:"name"` + Type string `json:"type"` +} + +// ActionRunner represents a Runner +type ActionRunner struct { + ID int64 `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Busy bool `json:"busy"` + // currently unused as forgejo does not support ephemeral runners, but they are defined in gh api spec + Ephemeral bool `json:"ephemeral"` + Labels []*ActionRunnerLabel `json:"labels"` +} + +// ActionRunnersResponse returns Runners +type ActionRunnersResponse struct { + Entries []*ActionRunner `json:"runners"` + TotalCount int64 `json:"total_count"` +} diff --git a/routers/api/v1/admin/runners.go b/routers/api/v1/admin/runners.go index e459b947ff..e77ea4df93 100644 --- a/routers/api/v1/admin/runners.go +++ b/routers/api/v1/admin/runners.go @@ -44,3 +44,81 @@ func SearchActionRunJobs(ctx *context.APIContext) { // "$ref": "#/responses/forbidden" shared.GetActionRunJobs(ctx, 0, 0) } + +// CreateRegistrationToken returns the token to register global runners +func CreateRegistrationToken(ctx *context.APIContext) { + // swagger:operation POST /admin/actions/runners/registration-token admin adminCreateRunnerRegistrationToken + // --- + // summary: Get an global actions runner registration token + // produces: + // - application/json + // parameters: + // responses: + // "200": + // "$ref": "#/responses/RegistrationToken" + + shared.GetRegistrationToken(ctx, 0, 0) +} + +// ListRunners get all runners +func ListRunners(ctx *context.APIContext) { + // swagger:operation GET /admin/actions/runners admin getAdminRunners + // --- + // summary: Get all runners + // produces: + // - application/json + // responses: + // "200": + // "$ref": "#/definitions/ActionRunnersResponse" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + shared.ListRunners(ctx, 0, 0) +} + +// GetRunner get an global runner +func GetRunner(ctx *context.APIContext) { + // swagger:operation GET /admin/actions/runners/{runner_id} admin getAdminRunner + // --- + // summary: Get an global runner + // produces: + // - application/json + // parameters: + // - name: runner_id + // in: path + // description: id of the runner + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/definitions/ActionRunner" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + shared.GetRunner(ctx, 0, 0, ctx.ParamsInt64("runner_id")) +} + +// DeleteRunner delete an global runner +func DeleteRunner(ctx *context.APIContext) { + // swagger:operation DELETE /admin/actions/runners/{runner_id} admin deleteAdminRunner + // --- + // summary: Delete an global runner + // produces: + // - application/json + // parameters: + // - name: runner_id + // in: path + // description: id of the runner + // type: string + // required: true + // responses: + // "204": + // description: runner has been deleted + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + shared.DeleteRunner(ctx, 0, 0, ctx.ParamsInt64("runner_id")) +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 26a2c0ffe3..8cfc44cff8 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -821,7 +821,11 @@ func Routes() *web.Route { }) m.Group("/runners", func() { + m.Get("", reqToken(), reqChecker, act.ListRunners) m.Get("/registration-token", reqToken(), reqChecker, act.GetRegistrationToken) + m.Post("/registration-token", reqToken(), reqChecker, act.CreateRegistrationToken) + m.Get("/{runner_id}", reqToken(), reqChecker, act.GetRunner) + m.Delete("/{runner_id}", reqToken(), reqChecker, act.DeleteRunner) m.Get("/jobs", reqToken(), reqChecker, act.SearchActionRunJobs) }) }) @@ -979,7 +983,11 @@ func Routes() *web.Route { }) m.Group("/runners", func() { + m.Get("", reqToken(), user.ListRunners) m.Get("/registration-token", reqToken(), user.GetRegistrationToken) + m.Post("/registration-token", reqToken(), user.CreateRegistrationToken) + m.Get("/{runner_id}", reqToken(), user.GetRunner) + m.Delete("/{runner_id}", reqToken(), user.DeleteRunner) m.Get("/jobs", reqToken(), user.SearchActionRunJobs) }) }) @@ -1653,6 +1661,12 @@ func Routes() *web.Route { Patch(bind(api.EditHookOption{}), admin.EditHook). Delete(admin.DeleteHook) }) + m.Group("/actions/runners", func() { + m.Get("", admin.ListRunners) + m.Post("/registration-token", admin.CreateRegistrationToken) + m.Get("/{runner_id}", admin.GetRunner) + m.Delete("/{runner_id}", admin.DeleteRunner) + }) m.Group("/runners", func() { m.Get("/registration-token", admin.GetRegistrationToken) m.Get("/jobs", admin.SearchActionRunJobs) diff --git a/routers/api/v1/org/action.go b/routers/api/v1/org/action.go index 8b330aa752..94834518a5 100644 --- a/routers/api/v1/org/action.go +++ b/routers/api/v1/org/action.go @@ -214,6 +214,27 @@ func (Action) SearchActionRunJobs(ctx *context.APIContext) { shared.GetActionRunJobs(ctx, ctx.Org.Organization.ID, 0) } +// https://docs.github.com/en/rest/actions/self-hosted-runners?apiVersion=2022-11-28#create-a-registration-token-for-an-organization +// CreateRegistrationToken returns the token to register org runners +func (Action) CreateRegistrationToken(ctx *context.APIContext) { + // swagger:operation POST /orgs/{org}/actions/runners/registration-token organization orgCreateRunnerRegistrationToken + // --- + // summary: Get an organization's actions runner registration token + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/RegistrationToken" + + shared.GetRegistrationToken(ctx, ctx.Org.Organization.ID, 0) +} + // ListVariables list org-level variables func (Action) ListVariables(ctx *context.APIContext) { // swagger:operation GET /orgs/{org}/actions/variables organization getOrgVariablesList @@ -266,6 +287,85 @@ func (Action) ListVariables(ctx *context.APIContext) { ctx.JSON(http.StatusOK, variables) } +// ListRunners get org-level runners +func (Action) ListRunners(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/actions/runners organization getOrgRunners + // --- + // summary: Get org-level runners + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/definitions/ActionRunnersResponse" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + shared.ListRunners(ctx, ctx.Org.Organization.ID, 0) +} + +// GetRunner get an org-level runner +func (Action) GetRunner(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/actions/runners/{runner_id} organization getOrgRunner + // --- + // summary: Get an org-level runner + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: runner_id + // in: path + // description: id of the runner + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/definitions/ActionRunner" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + shared.GetRunner(ctx, ctx.Org.Organization.ID, 0, ctx.ParamsInt64("runner_id")) +} + +// DeleteRunner delete an org-level runner +func (Action) DeleteRunner(ctx *context.APIContext) { + // swagger:operation DELETE /orgs/{org}/actions/runners/{runner_id} organization deleteOrgRunner + // --- + // summary: Delete an org-level runner + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: runner_id + // in: path + // description: id of the runner + // type: string + // required: true + // responses: + // "204": + // description: runner has been deleted + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + shared.DeleteRunner(ctx, ctx.Org.Organization.ID, 0, ctx.ParamsInt64("runner_id")) +} + // GetVariable gives organization's variable func (Action) GetVariable(ctx *context.APIContext) { // swagger:operation GET /orgs/{org}/actions/variables/{variablename} organization getOrgVariable diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index fe29d534ff..0d21a12c4f 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -508,6 +508,125 @@ func (Action) GetRegistrationToken(ctx *context.APIContext) { shared.GetRegistrationToken(ctx, 0, ctx.Repo.Repository.ID) } +// CreateRegistrationToken returns the token to register repo runners +func (Action) CreateRegistrationToken(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/actions/runners/registration-token repository repoCreateRunnerRegistrationToken + // --- + // summary: Get a repository's actions runner registration token + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/RegistrationToken" + + shared.GetRegistrationToken(ctx, 0, ctx.Repo.Repository.ID) +} + +// ListRunners get repo-level runners +func (Action) ListRunners(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/actions/runners repository getRepoRunners + // --- + // summary: Get repo-level runners + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/definitions/ActionRunnersResponse" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + shared.ListRunners(ctx, 0, ctx.Repo.Repository.ID) +} + +// GetRunner get an repo-level runner +func (Action) GetRunner(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/actions/runners/{runner_id} repository getRepoRunner + // --- + // summary: Get an repo-level runner + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: runner_id + // in: path + // description: id of the runner + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/definitions/ActionRunner" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + shared.GetRunner(ctx, 0, ctx.Repo.Repository.ID, ctx.ParamsInt64("runner_id")) +} + +// DeleteRunner delete an repo-level runner +func (Action) DeleteRunner(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/actions/runners/{runner_id} repository deleteRepoRunner + // --- + // summary: Delete an repo-level runner + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: runner_id + // in: path + // description: id of the runner + // type: string + // required: true + // responses: + // "204": + // description: runner has been deleted + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + shared.DeleteRunner(ctx, 0, ctx.Repo.Repository.ID, ctx.ParamsInt64("runner_id")) +} + // SearchActionRunJobs return a list of actions jobs filtered by the provided parameters func (Action) SearchActionRunJobs(ctx *context.APIContext) { // swagger:operation GET /repos/{owner}/{repo}/actions/runners/jobs repository repoSearchRunJobs diff --git a/routers/api/v1/shared/runners.go b/routers/api/v1/shared/runners.go index a7811a95b5..193ce2b717 100644 --- a/routers/api/v1/shared/runners.go +++ b/routers/api/v1/shared/runners.go @@ -13,6 +13,10 @@ import ( "forgejo.org/modules/structs" "forgejo.org/modules/util" "forgejo.org/services/context" + + "forgejo.org/modules/setting" + "forgejo.org/routers/api/v1/utils" + "forgejo.org/services/convert" ) // RegistrationToken is a string used to register a runner with a server @@ -70,3 +74,84 @@ func fromRunJobModelToResponse(job []*actions_model.ActionRunJob, labels []strin } return res } + +// ListRunners lists runners for api route validated ownerID and repoID +// ownerID == 0 and repoID == 0 means all runners including global runners, does not appear in sql where clause +// ownerID == 0 and repoID != 0 means all runners for the given repo +// ownerID != 0 and repoID == 0 means all runners for the given user/org +// ownerID != 0 and repoID != 0 undefined behavior +// Access rights are checked at the API route level +func ListRunners(ctx *context.APIContext, ownerID, repoID int64) { + if ownerID != 0 && repoID != 0 { + setting.PanicInDevOrTesting("ownerID and repoID should not be both set") + } + runners, total, err := db.FindAndCount[actions_model.ActionRunner](ctx, &actions_model.FindRunnerOptions{ + OwnerID: ownerID, + RepoID: repoID, + ListOptions: utils.GetListOptions(ctx), + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "FindCountRunners", map[string]string{}) + return + } + + res := new(structs.ActionRunnersResponse) + res.TotalCount = total + + res.Entries = make([]*structs.ActionRunner, len(runners)) + for i, runner := range runners { + res.Entries[i] = convert.ToActionRunner(ctx, runner) + } + + ctx.JSON(http.StatusOK, &res) +} + +// GetRunner get the runner for api route validated ownerID and repoID +// ownerID == 0 and repoID == 0 means any runner including global runners +// ownerID == 0 and repoID != 0 means any runner for the given repo +// ownerID != 0 and repoID == 0 means any runner for the given user/org +// ownerID != 0 and repoID != 0 undefined behavior +// Access rights are checked at the API route level +func GetRunner(ctx *context.APIContext, ownerID, repoID, runnerID int64) { + if ownerID != 0 && repoID != 0 { + setting.PanicInDevOrTesting("ownerID and repoID should not be both set") + } + runner, err := actions_model.GetRunnerByID(ctx, runnerID) + if err != nil { + ctx.Error(404, "GetRunnerByID", err) + return + } + if !runner.Editable(ownerID, repoID) { + ctx.Error(404, "RunnerEdit", "No permission to get this runner") + return + } + ctx.JSON(http.StatusOK, convert.ToActionRunner(ctx, runner)) +} + +// DeleteRunner deletes the runner for api route validated ownerID and repoID +// ownerID == 0 and repoID == 0 means any runner including global runners +// ownerID == 0 and repoID != 0 means any runner for the given repo +// ownerID != 0 and repoID == 0 means any runner for the given user/org +// ownerID != 0 and repoID != 0 undefined behavior +// Access rights are checked at the API route level +func DeleteRunner(ctx *context.APIContext, ownerID, repoID, runnerID int64) { + if ownerID != 0 && repoID != 0 { + setting.PanicInDevOrTesting("ownerID and repoID should not be both set") + } + runner, err := actions_model.GetRunnerByID(ctx, runnerID) + if err != nil { + ctx.InternalServerError(err) + return + } + if !runner.Editable(ownerID, repoID) { + ctx.Error(404, "EditRunner", "No permission to delete this runner") + return + } + + err = actions_model.DeleteRunner(ctx, runner) + if err != nil { + ctx.InternalServerError(err) + return + } + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/user/runners.go b/routers/api/v1/user/runners.go index 579e3eb932..b4bfd35efc 100644 --- a/routers/api/v1/user/runners.go +++ b/routers/api/v1/user/runners.go @@ -50,3 +50,81 @@ func SearchActionRunJobs(ctx *context.APIContext) { // "$ref": "#/responses/forbidden" shared.GetActionRunJobs(ctx, ctx.Doer.ID, 0) } + +// CreateRegistrationToken returns the token to register user runners +func CreateRegistrationToken(ctx *context.APIContext) { + // swagger:operation POST /user/actions/runners/registration-token user userCreateRunnerRegistrationToken + // --- + // summary: Get an user's actions runner registration token + // produces: + // - application/json + // parameters: + // responses: + // "200": + // "$ref": "#/responses/RegistrationToken" + + shared.GetRegistrationToken(ctx, ctx.Doer.ID, 0) +} + +// ListRunners get user-level runners +func ListRunners(ctx *context.APIContext) { + // swagger:operation GET /user/actions/runners user getUserRunners + // --- + // summary: Get user-level runners + // produces: + // - application/json + // responses: + // "200": + // "$ref": "#/definitions/ActionRunnersResponse" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + shared.ListRunners(ctx, ctx.Doer.ID, 0) +} + +// GetRunner get an user-level runner +func GetRunner(ctx *context.APIContext) { + // swagger:operation GET /user/actions/runners/{runner_id} user getUserRunner + // --- + // summary: Get an user-level runner + // produces: + // - application/json + // parameters: + // - name: runner_id + // in: path + // description: id of the runner + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/definitions/ActionRunner" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + shared.GetRunner(ctx, ctx.Doer.ID, 0, ctx.ParamsInt64("runner_id")) +} + +// DeleteRunner delete an user-level runner +func DeleteRunner(ctx *context.APIContext) { + // swagger:operation DELETE /user/actions/runners/{runner_id} user deleteUserRunner + // --- + // summary: Delete an user-level runner + // produces: + // - application/json + // parameters: + // - name: runner_id + // in: path + // description: id of the runner + // type: string + // required: true + // responses: + // "204": + // description: runner has been deleted + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + shared.DeleteRunner(ctx, ctx.Doer.ID, 0, ctx.ParamsInt64("runner_id")) +} diff --git a/services/actions/interface.go b/services/actions/interface.go index 54a30061bc..a5e0543594 100644 --- a/services/actions/interface.go +++ b/services/actions/interface.go @@ -27,4 +27,12 @@ type API interface { GetRegistrationToken(*context.APIContext) // SearchActionRunJobs get pending Action run jobs SearchActionRunJobs(*context.APIContext) + // CreateRegistrationToken get registration token + CreateRegistrationToken(*context.APIContext) + // ListRunners list runners + ListRunners(*context.APIContext) + // GetRunner get a runner + GetRunner(*context.APIContext) + // DeleteRunner delete runner + DeleteRunner(*context.APIContext) } diff --git a/services/convert/convert.go b/services/convert/convert.go index 2ea24a1b51..a6888127a8 100644 --- a/services/convert/convert.go +++ b/services/convert/convert.go @@ -11,6 +11,7 @@ import ( "strings" "time" + runnerv1 "code.gitea.io/actions-proto-go/runner/v1" actions_model "forgejo.org/models/actions" asymkey_model "forgejo.org/models/asymkey" "forgejo.org/models/auth" @@ -508,3 +509,27 @@ func ToChangedFile(f *gitdiff.DiffFile, repo *repo_model.Repository, commit stri return file } + +func ToActionRunner(ctx context.Context, runner *actions_model.ActionRunner) *api.ActionRunner { + status := runner.Status() + apiStatus := "offline" + if runner.IsOnline() { + apiStatus = "online" + } + labels := make([]*api.ActionRunnerLabel, len(runner.AgentLabels)) + for i, label := range runner.AgentLabels { + labels[i] = &api.ActionRunnerLabel{ + ID: int64(i), + Name: label, + Type: "custom", + } + } + return &api.ActionRunner{ + ID: runner.ID, + Name: runner.Name, + Status: apiStatus, + Busy: status == runnerv1.RunnerStatus_RUNNER_STATUS_ACTIVE, + // Ephemeral: runner.Ephemeral, + Labels: labels, + } +}