feat: add HTTP API endpoint for runner registration (#10677)

Add an HTTP API endpoint for runner registration. It enables managing the entire runner lifecycle using Forgejo's HTTP API. See https://code.forgejo.org/forgejo/forgejo-actions-feature-requests/issues/78 for background, design considerations, and usage.

Example usage:

```
$ curl -X POST -H "Content-Type: application/json" -H "Accept: application/json" -H "Authorization: token 3fc3ef39805b0f811a5d7789cb7b448348d6bfbb" --data '{"name":"api-runner","description":"Lorem ipsum"}' http://localhost:3000/api/v1/user/actions/runners
```
```json
{"id":30,"uuid":"a5e33697-9f58-437d-83c3-551b6c6a6334","token":"cac45fa6726fe4e28f42598773671af28a3be121"}
```

## Checklist

The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org).

### Tests

- I added test coverage for Go changes...
  - [ ] in their respective `*_test.go` for unit tests.
  - [x] in the `tests/integration` directory if it involves interactions with a live Forgejo server.
- I added test coverage for JavaScript changes...
  - [ ] in `web_src/js/*.test.js` if it can be unit tested.
  - [ ] in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/README.md#end-to-end-tests)).

### Documentation

- [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change.
- [x] I did not document these changes and I do not expect someone else to do it.

### Release notes

- [ ] I do not want this change to show in the release notes.
- [ ] I want the title to show in the release notes with a link to this pull request.
- [ ] I want the content of the `release-notes/<pull request number>.md` to be be used for the release notes instead of the title.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10677
Reviewed-by: Mathieu Fenniak <mfenniak@noreply.codeberg.org>
Co-authored-by: Andreas Ahlenstorf <andreas@ahlenstorf.ch>
Co-committed-by: Andreas Ahlenstorf <andreas@ahlenstorf.ch>
This commit is contained in:
Andreas Ahlenstorf 2026-01-05 04:59:04 +01:00 committed by Mathieu Fenniak
parent 28e0af23fa
commit 0837c8d8be
15 changed files with 714 additions and 22 deletions

26
modules/structs/runner.go Normal file
View file

@ -0,0 +1,26 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package structs
// RegisterRunnerOptions declares the accepted options for registering runners.
// swagger:model
type RegisterRunnerOptions struct {
// Name of the runner to register. The name of the runner does not have to be unique.
//
// required: true
Name string `json:"name" binding:"Required"`
// Description of the runner to register.
//
// required: false
Description string `json:"description"`
}
// RegisterRunnerResponse contains the details of the just registered runner.
// swagger:model
type RegisterRunnerResponse struct {
ID int64 `json:"id" binding:"Required"`
UUID string `json:"uuid" binding:"Required"`
Token string `json:"token" binding:"Required"`
}

View file

@ -139,6 +139,33 @@ func GetRunner(ctx *context.APIContext) {
shared.GetRunner(ctx, 0, 0, ctx.ParamsInt64("runner_id"))
}
// RegisterRunner registers a new global runner
func RegisterRunner(ctx *context.APIContext) {
// swagger:operation POST /admin/actions/runners admin registerAdminRunner
// ---
// summary: Register a new global runner
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/RegisterRunnerOptions"
// responses:
// "201":
// "$ref": "#/responses/RegisterRunnerResponse"
// "400":
// "$ref": "#/responses/error"
// "401":
// "$ref": "#/responses/unauthorized"
// "404":
// "$ref": "#/responses/notFound"
shared.RegisterRunner(ctx, 0, 0)
}
// DeleteRunner removes a particular runner, no matter whether it is a global runner or scoped to an organization, user, or repository
func DeleteRunner(ctx *context.APIContext) {
// swagger:operation DELETE /admin/actions/runners/{runner_id} admin deleteAdminRunner

View file

@ -856,7 +856,9 @@ func Routes() *web.Route {
})
m.Group("/runners", func() {
m.Get("", reqToken(), reqChecker, act.ListRunners)
m.Combo("").
Get(reqToken(), reqChecker, act.ListRunners).
Post(reqToken(), reqChecker, bind(api.RegisterRunnerOptions{}), act.RegisterRunner)
m.Get("/registration-token", reqToken(), reqChecker, act.GetRegistrationToken)
m.Get("/{runner_id}", reqToken(), reqChecker, act.GetRunner)
m.Delete("/{runner_id}", reqToken(), reqChecker, act.DeleteRunner)
@ -1019,7 +1021,9 @@ func Routes() *web.Route {
})
m.Group("/runners", func() {
m.Get("", reqToken(), user.ListRunners)
m.Combo("").
Get(reqToken(), user.ListRunners).
Post(bind(api.RegisterRunnerOptions{}), user.RegisterRunner)
m.Get("/registration-token", reqToken(), user.GetRegistrationToken)
m.Get("/{runner_id}", reqToken(), user.GetRunner)
m.Delete("/{runner_id}", reqToken(), user.DeleteRunner)
@ -1701,7 +1705,9 @@ func Routes() *web.Route {
Delete(admin.DeleteHook)
})
m.Group("/actions/runners", func() {
m.Get("", admin.ListRunners)
m.Combo("").
Get(admin.ListRunners).
Post(bind(api.RegisterRunnerOptions{}), admin.RegisterRunner)
m.Get("/registration-token", admin.GetRunnerRegistrationToken)
m.Get("/{runner_id}", admin.GetRunner)
m.Delete("/{runner_id}", admin.DeleteRunner)

View file

@ -324,6 +324,38 @@ func (Action) GetRunner(ctx *context.APIContext) {
shared.GetRunner(ctx, ctx.Org.Organization.ID, 0, ctx.ParamsInt64("runner_id"))
}
// RegisterRunner registers a new organization-level runner
func (Action) RegisterRunner(ctx *context.APIContext) {
// swagger:operation POST /orgs/{org}/actions/runners organization registerOrgRunner
// ---
// summary: Register a new organization-level runner
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of the organization
// type: string
// required: true
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/RegisterRunnerOptions"
// responses:
// "201":
// "$ref": "#/responses/RegisterRunnerResponse"
// "400":
// "$ref": "#/responses/error"
// "401":
// "$ref": "#/responses/unauthorized"
// "404":
// "$ref": "#/responses/notFound"
shared.RegisterRunner(ctx, ctx.Org.Organization.ID, 0)
}
// DeleteRunner removes a particular runner that belongs to the organization
func (Action) DeleteRunner(ctx *context.APIContext) {
// swagger:operation DELETE /orgs/{org}/actions/runners/{runner_id} organization deleteOrgRunner

View file

@ -574,6 +574,43 @@ func (Action) GetRunner(ctx *context.APIContext) {
shared.GetRunner(ctx, 0, ctx.Repo.Repository.ID, ctx.ParamsInt64("runner_id"))
}
// RegisterRunner registers a new repository-level runner
func (Action) RegisterRunner(ctx *context.APIContext) {
// swagger:operation POST /repos/{owner}/{repo}/actions/runners repository registerRepoRunner
// ---
// summary: Register a new repository-level runner
// consumes:
// - application/json
// 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: body
// in: body
// schema:
// "$ref": "#/definitions/RegisterRunnerOptions"
// responses:
// "201":
// "$ref": "#/responses/RegisterRunnerResponse"
// "400":
// "$ref": "#/responses/error"
// "401":
// "$ref": "#/responses/unauthorized"
// "404":
// "$ref": "#/responses/notFound"
shared.RegisterRunner(ctx, 0, ctx.Repo.Repository.ID)
}
// DeleteRunner removes a particular runner that belongs to a repository
func (Action) DeleteRunner(ctx *context.APIContext) {
// swagger:operation DELETE /repos/{owner}/{repo}/actions/runners/{runner_id} repository deleteRepoRunner

View file

@ -13,9 +13,12 @@ import (
"forgejo.org/models/db"
"forgejo.org/modules/structs"
"forgejo.org/modules/util"
"forgejo.org/modules/web"
"forgejo.org/routers/api/v1/utils"
"forgejo.org/services/context"
"forgejo.org/services/convert"
gouuid "github.com/google/uuid"
)
// RegistrationToken is a string used to register a runner with a server
@ -146,6 +149,33 @@ func GetRunner(ctx *context.APIContext, ownerID, repoID, runnerID int64) {
ctx.JSON(http.StatusOK, actionRunner)
}
func RegisterRunner(ctx *context.APIContext, ownerID, repoID int64) {
if ownerID != 0 && repoID != 0 {
ctx.Error(http.StatusUnprocessableEntity, "RegisterRunner", fmt.Errorf("ownerID '%d' and repoID '%d' cannot be set simultaneously", ownerID, repoID))
return
}
options := web.GetForm(ctx).(*structs.RegisterRunnerOptions)
runner := &actions_model.ActionRunner{
UUID: gouuid.NewString(),
Name: options.Name,
OwnerID: ownerID,
RepoID: repoID,
Description: options.Description,
}
runner.GenerateToken()
if err := actions_model.CreateRunner(ctx, runner); err != nil {
ctx.Error(http.StatusInternalServerError, "CreateRunner", err)
}
response := &structs.RegisterRunnerResponse{
ID: runner.ID,
UUID: runner.UUID,
Token: runner.Token,
}
ctx.JSON(http.StatusCreated, response)
}
// 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

View file

@ -76,3 +76,10 @@ type swaggerActionRunnerListResponse struct {
// Links to other pages, if any
Link string `json:"Link"`
}
// RegisterRunnerResponse contains the details of the just registered runner.
// swagger:response RegisterRunnerResponse
type swaggerRegisterRunnerResponse struct {
// in: body
Body api.RegisterRunnerResponse `json:"body"`
}

View file

@ -239,4 +239,7 @@ type swaggerParameterBodies struct {
// in:body
NoteOptions api.NoteOptions
// in:body
RegisterRunnerOptions api.RegisterRunnerOptions
}

View file

@ -102,6 +102,33 @@ func GetRunner(ctx *context.APIContext) {
shared.GetRunner(ctx, ctx.Doer.ID, 0, ctx.ParamsInt64("runner_id"))
}
// RegisterRunner registers a new user-level runner
func RegisterRunner(ctx *context.APIContext) {
// swagger:operation POST /user/actions/runners user registerUserRunner
// ---
// summary: Register a new user-level runner
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/RegisterRunnerOptions"
// responses:
// "201":
// "$ref": "#/responses/RegisterRunnerResponse"
// "400":
// "$ref": "#/responses/error"
// "401":
// "$ref": "#/responses/unauthorized"
// "404":
// "$ref": "#/responses/notFound"
shared.RegisterRunner(ctx, ctx.Doer.ID, 0)
}
// DeleteRunner deletes a particular user-level runner
func DeleteRunner(ctx *context.APIContext) {
// swagger:operation DELETE /user/actions/runners/{runner_id} user deleteUserRunner

View file

@ -31,6 +31,8 @@ type API interface {
ListRunners(*context.APIContext)
// GetRunner get a runner
GetRunner(*context.APIContext)
// RegisterRunner registers a new runner
RegisterRunner(*context.APIContext)
// DeleteRunner delete runner
DeleteRunner(*context.APIContext)
}

View file

@ -346,6 +346,42 @@
"$ref": "#/responses/notFound"
}
}
},
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"admin"
],
"summary": "Register a new global runner",
"operationId": "registerAdminRunner",
"parameters": [
{
"name": "body",
"in": "body",
"schema": {
"$ref": "#/definitions/RegisterRunnerOptions"
}
}
],
"responses": {
"201": {
"$ref": "#/responses/RegisterRunnerResponse"
},
"400": {
"$ref": "#/responses/error"
},
"401": {
"$ref": "#/responses/unauthorized"
},
"404": {
"$ref": "#/responses/notFound"
}
}
}
},
"/admin/actions/runners/jobs": {
@ -2715,6 +2751,49 @@
"$ref": "#/responses/notFound"
}
}
},
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"organization"
],
"summary": "Register a new organization-level runner",
"operationId": "registerOrgRunner",
"parameters": [
{
"type": "string",
"description": "name of the organization",
"name": "org",
"in": "path",
"required": true
},
{
"name": "body",
"in": "body",
"schema": {
"$ref": "#/definitions/RegisterRunnerOptions"
}
}
],
"responses": {
"201": {
"$ref": "#/responses/RegisterRunnerResponse"
},
"400": {
"$ref": "#/responses/error"
},
"401": {
"$ref": "#/responses/unauthorized"
},
"404": {
"$ref": "#/responses/notFound"
}
}
}
},
"/orgs/{org}/actions/runners/jobs": {
@ -5408,6 +5487,56 @@
"$ref": "#/responses/notFound"
}
}
},
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"repository"
],
"summary": "Register a new repository-level runner",
"operationId": "registerRepoRunner",
"parameters": [
{
"type": "string",
"description": "owner of the repo",
"name": "owner",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the repo",
"name": "repo",
"in": "path",
"required": true
},
{
"name": "body",
"in": "body",
"schema": {
"$ref": "#/definitions/RegisterRunnerOptions"
}
}
],
"responses": {
"201": {
"$ref": "#/responses/RegisterRunnerResponse"
},
"400": {
"$ref": "#/responses/error"
},
"401": {
"$ref": "#/responses/unauthorized"
},
"404": {
"$ref": "#/responses/notFound"
}
}
}
},
"/repos/{owner}/{repo}/actions/runners/jobs": {
@ -18852,6 +18981,42 @@
"$ref": "#/responses/notFound"
}
}
},
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"user"
],
"summary": "Register a new user-level runner",
"operationId": "registerUserRunner",
"parameters": [
{
"name": "body",
"in": "body",
"schema": {
"$ref": "#/definitions/RegisterRunnerOptions"
}
}
],
"responses": {
"201": {
"$ref": "#/responses/RegisterRunnerResponse"
},
"400": {
"$ref": "#/responses/error"
},
"401": {
"$ref": "#/responses/unauthorized"
},
"404": {
"$ref": "#/responses/notFound"
}
}
}
},
"/user/actions/runners/jobs": {
@ -28320,6 +28485,46 @@
},
"x-go-package": "forgejo.org/modules/structs"
},
"RegisterRunnerOptions": {
"type": "object",
"title": "RegisterRunnerOptions declares the accepted options for registering runners.",
"required": [
"name"
],
"properties": {
"description": {
"description": "Description of the runner to register.",
"type": "string",
"x-go-name": "Description"
},
"name": {
"description": "Name of the runner to register. The name of the runner does not have to be unique.",
"type": "string",
"x-go-name": "Name"
}
},
"x-go-package": "forgejo.org/modules/structs"
},
"RegisterRunnerResponse": {
"type": "object",
"title": "RegisterRunnerResponse contains the details of the just registered runner.",
"properties": {
"id": {
"type": "integer",
"format": "int64",
"x-go-name": "ID"
},
"token": {
"type": "string",
"x-go-name": "Token"
},
"uuid": {
"type": "string",
"x-go-name": "UUID"
}
},
"x-go-package": "forgejo.org/modules/structs"
},
"RegistrationToken": {
"description": "RegistrationToken is a string used to register a runner with a server",
"type": "object",
@ -30715,6 +30920,12 @@
}
}
},
"RegisterRunnerResponse": {
"description": "RegisterRunnerResponse contains the details of the just registered runner.",
"schema": {
"$ref": "#/definitions/RegisterRunnerResponse"
}
},
"RegistrationToken": {
"description": "RegistrationToken is a string used to register a runner with a server",
"schema": {
@ -31039,7 +31250,7 @@
"parameterBodies": {
"description": "parameterBodies",
"schema": {
"$ref": "#/definitions/NoteOptions"
"$ref": "#/definitions/RegisterRunnerOptions"
}
},
"quotaExceeded": {

View file

@ -16,6 +16,7 @@ import (
"forgejo.org/routers/api/v1/shared"
"forgejo.org/tests"
gouuid "github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -182,7 +183,7 @@ func TestAPIAdminActionsRunnerOperations(t *testing.T) {
readToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadAdmin)
writeToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteAdmin)
t.Run("GetRunners", func(t *testing.T) {
t.Run("Get runners", func(t *testing.T) {
request := NewRequest(t, "GET", "/api/v1/admin/actions/runners")
request.AddTokenAuth(readToken)
response := MakeRequest(t, request, http.StatusOK)
@ -233,7 +234,7 @@ func TestAPIAdminActionsRunnerOperations(t *testing.T) {
assert.Contains(t, runners, runnerThree)
})
t.Run("GetRunnersPaginated", func(t *testing.T) {
t.Run("Get runners paginated", func(t *testing.T) {
request := NewRequest(t, "GET", "/api/v1/admin/actions/runners?page=1&limit=5")
request.AddTokenAuth(readToken)
response := MakeRequest(t, request, http.StatusOK)
@ -246,7 +247,7 @@ func TestAPIAdminActionsRunnerOperations(t *testing.T) {
assert.Len(t, runners, 5)
})
t.Run("GetGlobalRunner", func(t *testing.T) {
t.Run("Get global runner", func(t *testing.T) {
request := NewRequest(t, "GET", "/api/v1/admin/actions/runners/130793")
request.AddTokenAuth(readToken)
response := MakeRequest(t, request, http.StatusOK)
@ -269,7 +270,7 @@ func TestAPIAdminActionsRunnerOperations(t *testing.T) {
assert.Equal(t, runnerOne, runner)
})
t.Run("GetRepositoryScopedRunner", func(t *testing.T) {
t.Run("Get repository-scoped runner", func(t *testing.T) {
request := NewRequest(t, "GET", "/api/v1/admin/actions/runners/130794")
request.AddTokenAuth(readToken)
response := MakeRequest(t, request, http.StatusOK)
@ -292,7 +293,7 @@ func TestAPIAdminActionsRunnerOperations(t *testing.T) {
assert.Equal(t, runnerFour, runner)
})
t.Run("DeleteGlobalRunner", func(t *testing.T) {
t.Run("Delete global runner", func(t *testing.T) {
url := "/api/v1/admin/actions/runners/130791"
request := NewRequest(t, "GET", url)
@ -308,7 +309,7 @@ func TestAPIAdminActionsRunnerOperations(t *testing.T) {
MakeRequest(t, request, http.StatusNotFound)
})
t.Run("DeleteRepositoryScopedRunner", func(t *testing.T) {
t.Run("Delete repository-scoped runner", func(t *testing.T) {
url := "/api/v1/admin/actions/runners/130794"
request := NewRequest(t, "GET", url)
@ -323,4 +324,73 @@ func TestAPIAdminActionsRunnerOperations(t *testing.T) {
request.AddTokenAuth(readToken)
MakeRequest(t, request, http.StatusNotFound)
})
t.Run("Register runner", func(t *testing.T) {
options := api.RegisterRunnerOptions{Name: "api-runner", Description: "Some description"}
request := NewRequestWithJSON(t, "POST", "/api/v1/admin/actions/runners", options)
request.AddTokenAuth(writeToken)
response := MakeRequest(t, request, http.StatusCreated)
var registerRunnerResponse *api.RegisterRunnerResponse
DecodeJSON(t, response, &registerRunnerResponse)
assert.NotNil(t, registerRunnerResponse)
assert.Positive(t, registerRunnerResponse.ID)
assert.Equal(t, gouuid.Version(4), gouuid.MustParse(registerRunnerResponse.UUID).Version())
assert.Regexp(t, "(?i)^[0-9a-f]{40}$", registerRunnerResponse.Token)
registeredRunner := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{UUID: registerRunnerResponse.UUID})
assert.Equal(t, registerRunnerResponse.ID, registeredRunner.ID)
assert.Equal(t, registerRunnerResponse.UUID, registeredRunner.UUID)
assert.Zero(t, registeredRunner.OwnerID)
assert.Zero(t, registeredRunner.RepoID)
assert.Equal(t, "api-runner", registeredRunner.Name)
assert.Equal(t, "Some description", registeredRunner.Description)
assert.Empty(t, registeredRunner.AgentLabels)
assert.Empty(t, registeredRunner.Version)
assert.NotEmpty(t, registeredRunner.TokenHash)
assert.NotEmpty(t, registeredRunner.TokenSalt)
})
t.Run("Runner registration does not update runner with identical name", func(t *testing.T) {
options := api.RegisterRunnerOptions{Name: "api-runner"}
request := NewRequestWithJSON(t, "POST", "/api/v1/admin/actions/runners", options)
request.AddTokenAuth(writeToken)
response := MakeRequest(t, request, http.StatusCreated)
var registerRunnerResponse *api.RegisterRunnerResponse
DecodeJSON(t, response, &registerRunnerResponse)
secondRequest := NewRequestWithJSON(t, "POST", "/api/v1/admin/actions/runners", options)
secondRequest.AddTokenAuth(writeToken)
secondResponse := MakeRequest(t, secondRequest, http.StatusCreated)
var secondRegisterRunnerResponse *api.RegisterRunnerResponse
DecodeJSON(t, secondResponse, &secondRegisterRunnerResponse)
firstRunner := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{UUID: registerRunnerResponse.UUID})
secondRunner := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{UUID: secondRegisterRunnerResponse.UUID})
assert.NotEqual(t, firstRunner.ID, secondRunner.ID)
assert.NotEqual(t, firstRunner.UUID, secondRunner.UUID)
})
t.Run("Runner registration requires write token for admin scope", func(t *testing.T) {
options := api.RegisterRunnerOptions{Name: "api-runner"}
request := NewRequestWithJSON(t, "POST", "/api/v1/admin/actions/runners", options)
request.AddTokenAuth(readToken)
response := MakeRequest(t, request, http.StatusForbidden)
type errorResponse struct {
Message string `json:"message"`
}
var errorMessage *errorResponse
DecodeJSON(t, response, &errorMessage)
assert.Equal(t, "token does not have at least one of required scope(s): [write:admin]", errorMessage.Message)
})
}

View file

@ -16,6 +16,7 @@ import (
"forgejo.org/routers/api/v1/shared"
"forgejo.org/tests"
gouuid "github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -111,7 +112,7 @@ func TestAPIOrgActionsRunnerOperations(t *testing.T) {
readToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadOrganization)
writeToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization)
t.Run("GetRunners", func(t *testing.T) {
t.Run("Get runners", func(t *testing.T) {
request := NewRequest(t, "GET", "/api/v1/orgs/org3/actions/runners")
request.AddTokenAuth(readToken)
response := MakeRequest(t, request, http.StatusOK)
@ -147,7 +148,7 @@ func TestAPIOrgActionsRunnerOperations(t *testing.T) {
assert.ElementsMatch(t, []*api.ActionRunner{runnerOne, runnerThree}, runners)
})
t.Run("GetRunnersPaginated", func(t *testing.T) {
t.Run("Get runners paginated", func(t *testing.T) {
request := NewRequest(t, "GET", "/api/v1/orgs/org3/actions/runners?page=1&limit=1")
request.AddTokenAuth(readToken)
response := MakeRequest(t, request, http.StatusOK)
@ -160,7 +161,7 @@ func TestAPIOrgActionsRunnerOperations(t *testing.T) {
assert.Len(t, runners, 1)
})
t.Run("GetRunner", func(t *testing.T) {
t.Run("Get runner", func(t *testing.T) {
request := NewRequest(t, "GET", "/api/v1/orgs/org3/actions/runners/655691")
request.AddTokenAuth(readToken)
response := MakeRequest(t, request, http.StatusOK)
@ -183,7 +184,7 @@ func TestAPIOrgActionsRunnerOperations(t *testing.T) {
assert.Equal(t, runnerOne, runner)
})
t.Run("DeleteRunner", func(t *testing.T) {
t.Run("Delete runner", func(t *testing.T) {
url := "/api/v1/orgs/org3/actions/runners/655691"
request := NewRequest(t, "GET", url)
@ -198,4 +199,73 @@ func TestAPIOrgActionsRunnerOperations(t *testing.T) {
request.AddTokenAuth(readToken)
MakeRequest(t, request, http.StatusNotFound)
})
t.Run("Register runner", func(t *testing.T) {
options := api.RegisterRunnerOptions{Name: "api-runner", Description: "Some description"}
request := NewRequestWithJSON(t, "POST", "/api/v1/orgs/org3/actions/runners", options)
request.AddTokenAuth(writeToken)
response := MakeRequest(t, request, http.StatusCreated)
var registerRunnerResponse *api.RegisterRunnerResponse
DecodeJSON(t, response, &registerRunnerResponse)
assert.NotNil(t, registerRunnerResponse)
assert.Positive(t, registerRunnerResponse.ID)
assert.Equal(t, gouuid.Version(4), gouuid.MustParse(registerRunnerResponse.UUID).Version())
assert.Regexp(t, "(?i)^[0-9a-f]{40}$", registerRunnerResponse.Token)
registeredRunner := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{UUID: registerRunnerResponse.UUID})
assert.Equal(t, registerRunnerResponse.ID, registeredRunner.ID)
assert.Equal(t, registerRunnerResponse.UUID, registeredRunner.UUID)
assert.Equal(t, int64(3), registeredRunner.OwnerID)
assert.Zero(t, registeredRunner.RepoID)
assert.Equal(t, "api-runner", registeredRunner.Name)
assert.Equal(t, "Some description", registeredRunner.Description)
assert.Empty(t, registeredRunner.AgentLabels)
assert.Empty(t, registeredRunner.Version)
assert.NotEmpty(t, registeredRunner.TokenHash)
assert.NotEmpty(t, registeredRunner.TokenSalt)
})
t.Run("Runner registration does not update runner with identical name", func(t *testing.T) {
options := api.RegisterRunnerOptions{Name: "api-runner"}
request := NewRequestWithJSON(t, "POST", "/api/v1/orgs/org3/actions/runners", options)
request.AddTokenAuth(writeToken)
response := MakeRequest(t, request, http.StatusCreated)
var registerRunnerResponse *api.RegisterRunnerResponse
DecodeJSON(t, response, &registerRunnerResponse)
secondRequest := NewRequestWithJSON(t, "POST", "/api/v1/orgs/org3/actions/runners", options)
secondRequest.AddTokenAuth(writeToken)
secondResponse := MakeRequest(t, secondRequest, http.StatusCreated)
var secondRegisterRunnerResponse *api.RegisterRunnerResponse
DecodeJSON(t, secondResponse, &secondRegisterRunnerResponse)
firstRunner := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{UUID: registerRunnerResponse.UUID})
secondRunner := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{UUID: secondRegisterRunnerResponse.UUID})
assert.NotEqual(t, firstRunner.ID, secondRunner.ID)
assert.NotEqual(t, firstRunner.UUID, secondRunner.UUID)
})
t.Run("Runner registration requires write token for organization scope", func(t *testing.T) {
options := api.RegisterRunnerOptions{Name: "api-runner"}
request := NewRequestWithJSON(t, "POST", "/api/v1/orgs/org3/actions/runners", options)
request.AddTokenAuth(readToken)
response := MakeRequest(t, request, http.StatusForbidden)
type errorResponse struct {
Message string `json:"message"`
}
var errorMessage *errorResponse
DecodeJSON(t, response, &errorMessage)
assert.Equal(t, "token does not have at least one of required scope(s): [write:organization]", errorMessage.Message)
})
}

View file

@ -23,6 +23,7 @@ import (
files_service "forgejo.org/services/repository/files"
"forgejo.org/tests"
gouuid "github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -380,11 +381,12 @@ func TestAPIRepoActionsRunnerOperations(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
session := loginUser(t, user2.Name)
readToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
writeToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
t.Run("GetRunners", func(t *testing.T) {
t.Run("Get runners", func(t *testing.T) {
request := NewRequest(t, "GET", "/api/v1/repos/user2/test_workflows/actions/runners")
request.AddTokenAuth(readToken)
response := MakeRequest(t, request, http.StatusOK)
@ -420,7 +422,7 @@ func TestAPIRepoActionsRunnerOperations(t *testing.T) {
assert.ElementsMatch(t, []*api.ActionRunner{runnerOne, runnerThree}, runners)
})
t.Run("GetRunnersPaginated", func(t *testing.T) {
t.Run("Get runners paginated", func(t *testing.T) {
request := NewRequest(t, "GET", "/api/v1/repos/user2/test_workflows/actions/runners?page=1&limit=1")
request.AddTokenAuth(readToken)
response := MakeRequest(t, request, http.StatusOK)
@ -433,7 +435,7 @@ func TestAPIRepoActionsRunnerOperations(t *testing.T) {
assert.Len(t, runners, 1)
})
t.Run("GetRunner", func(t *testing.T) {
t.Run("Get runner", func(t *testing.T) {
request := NewRequest(t, "GET", "/api/v1/repos/user2/test_workflows/actions/runners/899251")
request.AddTokenAuth(readToken)
response := MakeRequest(t, request, http.StatusOK)
@ -456,7 +458,7 @@ func TestAPIRepoActionsRunnerOperations(t *testing.T) {
assert.Equal(t, runnerOne, runner)
})
t.Run("DeleteRunner", func(t *testing.T) {
t.Run("Delete runner", func(t *testing.T) {
url := "/api/v1/repos/user2/test_workflows/actions/runners/899253"
request := NewRequest(t, "GET", url)
@ -471,4 +473,76 @@ func TestAPIRepoActionsRunnerOperations(t *testing.T) {
request.AddTokenAuth(readToken)
MakeRequest(t, request, http.StatusNotFound)
})
t.Run("Register runner", func(t *testing.T) {
options := api.RegisterRunnerOptions{Name: "api-runner", Description: "Some description"}
requestURL := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runners", repo1.OwnerName, repo1.Name)
request := NewRequestWithJSON(t, "POST", requestURL, options)
request.AddTokenAuth(writeToken)
response := MakeRequest(t, request, http.StatusCreated)
var registerRunnerResponse *api.RegisterRunnerResponse
DecodeJSON(t, response, &registerRunnerResponse)
assert.NotNil(t, registerRunnerResponse)
assert.Positive(t, registerRunnerResponse.ID)
assert.Equal(t, gouuid.Version(4), gouuid.MustParse(registerRunnerResponse.UUID).Version())
assert.Regexp(t, "(?i)^[0-9a-f]{40}$", registerRunnerResponse.Token)
registeredRunner := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{UUID: registerRunnerResponse.UUID})
assert.Equal(t, registerRunnerResponse.ID, registeredRunner.ID)
assert.Equal(t, registerRunnerResponse.UUID, registeredRunner.UUID)
assert.Zero(t, registeredRunner.OwnerID)
assert.Equal(t, repo1.ID, registeredRunner.RepoID)
assert.Equal(t, "api-runner", registeredRunner.Name)
assert.Equal(t, "Some description", registeredRunner.Description)
assert.Empty(t, registeredRunner.AgentLabels)
assert.Empty(t, registeredRunner.Version)
assert.NotEmpty(t, registeredRunner.TokenHash)
assert.NotEmpty(t, registeredRunner.TokenSalt)
})
t.Run("Runner registration does not update runner with identical name", func(t *testing.T) {
options := api.RegisterRunnerOptions{Name: "api-runner"}
requestURL := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runners", repo1.OwnerName, repo1.Name)
request := NewRequestWithJSON(t, "POST", requestURL, options)
request.AddTokenAuth(writeToken)
response := MakeRequest(t, request, http.StatusCreated)
var registerRunnerResponse *api.RegisterRunnerResponse
DecodeJSON(t, response, &registerRunnerResponse)
secondRequest := NewRequestWithJSON(t, "POST", requestURL, options)
secondRequest.AddTokenAuth(writeToken)
secondResponse := MakeRequest(t, secondRequest, http.StatusCreated)
var secondRegisterRunnerResponse *api.RegisterRunnerResponse
DecodeJSON(t, secondResponse, &secondRegisterRunnerResponse)
firstRunner := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{UUID: registerRunnerResponse.UUID})
secondRunner := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{UUID: secondRegisterRunnerResponse.UUID})
assert.NotEqual(t, firstRunner.ID, secondRunner.ID)
assert.NotEqual(t, firstRunner.UUID, secondRunner.UUID)
})
t.Run("Runner registration requires write token for repository scope", func(t *testing.T) {
options := api.RegisterRunnerOptions{Name: "api-runner"}
requestURL := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runners", repo1.OwnerName, repo1.Name)
request := NewRequestWithJSON(t, "POST", requestURL, options)
request.AddTokenAuth(readToken)
response := MakeRequest(t, request, http.StatusForbidden)
type errorResponse struct {
Message string `json:"message"`
}
var errorMessage *errorResponse
DecodeJSON(t, response, &errorMessage)
assert.Equal(t, "token does not have at least one of required scope(s): [write:repository]", errorMessage.Message)
})
}

View file

@ -16,6 +16,7 @@ import (
"forgejo.org/routers/api/v1/shared"
"forgejo.org/tests"
gouuid "github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -109,7 +110,7 @@ func TestAPIUserActionsRunnerOperations(t *testing.T) {
readToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser)
writeToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser)
t.Run("GetRunners", func(t *testing.T) {
t.Run("Get runners", func(t *testing.T) {
request := NewRequest(t, "GET", "/api/v1/user/actions/runners")
request.AddTokenAuth(readToken)
response := MakeRequest(t, request, http.StatusOK)
@ -145,7 +146,7 @@ func TestAPIUserActionsRunnerOperations(t *testing.T) {
assert.ElementsMatch(t, []*api.ActionRunner{runnerOne, runnerThree}, runners)
})
t.Run("GetRunnersPaginated", func(t *testing.T) {
t.Run("Get runners paginated", func(t *testing.T) {
request := NewRequest(t, "GET", "/api/v1/user/actions/runners?page=1&limit=1")
request.AddTokenAuth(readToken)
response := MakeRequest(t, request, http.StatusOK)
@ -158,7 +159,7 @@ func TestAPIUserActionsRunnerOperations(t *testing.T) {
assert.Len(t, runners, 1)
})
t.Run("GetRunner", func(t *testing.T) {
t.Run("Get runner", func(t *testing.T) {
request := NewRequest(t, "GET", "/api/v1/user/actions/runners/71303")
request.AddTokenAuth(readToken)
response := MakeRequest(t, request, http.StatusOK)
@ -181,7 +182,7 @@ func TestAPIUserActionsRunnerOperations(t *testing.T) {
assert.Equal(t, runnerThree, runner)
})
t.Run("DeleteRunner", func(t *testing.T) {
t.Run("Delete runner", func(t *testing.T) {
url := "/api/v1/user/actions/runners/71303"
request := NewRequest(t, "GET", url)
@ -196,4 +197,73 @@ func TestAPIUserActionsRunnerOperations(t *testing.T) {
request.AddTokenAuth(readToken)
MakeRequest(t, request, http.StatusNotFound)
})
t.Run("Register runner", func(t *testing.T) {
options := api.RegisterRunnerOptions{Name: "api-runner", Description: "Some description"}
request := NewRequestWithJSON(t, "POST", "/api/v1/user/actions/runners", options)
request.AddTokenAuth(writeToken)
response := MakeRequest(t, request, http.StatusCreated)
var registerRunnerResponse *api.RegisterRunnerResponse
DecodeJSON(t, response, &registerRunnerResponse)
assert.NotNil(t, registerRunnerResponse)
assert.Positive(t, registerRunnerResponse.ID)
assert.Equal(t, gouuid.Version(4), gouuid.MustParse(registerRunnerResponse.UUID).Version())
assert.Regexp(t, "(?i)^[0-9a-f]{40}$", registerRunnerResponse.Token)
registeredRunner := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{UUID: registerRunnerResponse.UUID})
assert.Equal(t, registerRunnerResponse.ID, registeredRunner.ID)
assert.Equal(t, registerRunnerResponse.UUID, registeredRunner.UUID)
assert.Equal(t, user2.ID, registeredRunner.OwnerID)
assert.Zero(t, registeredRunner.RepoID)
assert.Equal(t, "api-runner", registeredRunner.Name)
assert.Equal(t, "Some description", registeredRunner.Description)
assert.Empty(t, registeredRunner.AgentLabels)
assert.Empty(t, registeredRunner.Version)
assert.NotEmpty(t, registeredRunner.TokenHash)
assert.NotEmpty(t, registeredRunner.TokenSalt)
})
t.Run("Runner registration does not update runner with identical name", func(t *testing.T) {
options := api.RegisterRunnerOptions{Name: "api-runner"}
request := NewRequestWithJSON(t, "POST", "/api/v1/user/actions/runners", options)
request.AddTokenAuth(writeToken)
response := MakeRequest(t, request, http.StatusCreated)
var registerRunnerResponse *api.RegisterRunnerResponse
DecodeJSON(t, response, &registerRunnerResponse)
secondRequest := NewRequestWithJSON(t, "POST", "/api/v1/user/actions/runners", options)
secondRequest.AddTokenAuth(writeToken)
secondResponse := MakeRequest(t, secondRequest, http.StatusCreated)
var secondRegisterRunnerResponse *api.RegisterRunnerResponse
DecodeJSON(t, secondResponse, &secondRegisterRunnerResponse)
firstRunner := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{UUID: registerRunnerResponse.UUID})
secondRunner := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{UUID: secondRegisterRunnerResponse.UUID})
assert.NotEqual(t, firstRunner.ID, secondRunner.ID)
assert.NotEqual(t, firstRunner.UUID, secondRunner.UUID)
})
t.Run("Runner registration requires write token for user scope", func(t *testing.T) {
options := api.RegisterRunnerOptions{Name: "api-runner"}
request := NewRequestWithJSON(t, "POST", "/api/v1/user/actions/runners", options)
request.AddTokenAuth(readToken)
response := MakeRequest(t, request, http.StatusForbidden)
type errorResponse struct {
Message string `json:"message"`
}
var errorMessage *errorResponse
DecodeJSON(t, response, &errorMessage)
assert.Equal(t, "token does not have at least one of required scope(s): [write:user]", errorMessage.Message)
})
}