diff --git a/modules/structs/runner.go b/modules/structs/runner.go new file mode 100644 index 0000000000..e744355b30 --- /dev/null +++ b/modules/structs/runner.go @@ -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"` +} diff --git a/routers/api/v1/admin/runners.go b/routers/api/v1/admin/runners.go index b663eb57a3..81c029c57c 100644 --- a/routers/api/v1/admin/runners.go +++ b/routers/api/v1/admin/runners.go @@ -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 diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 9029ba834b..b7bb74e558 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -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) diff --git a/routers/api/v1/org/action.go b/routers/api/v1/org/action.go index f30d30f17e..dac6704d4b 100644 --- a/routers/api/v1/org/action.go +++ b/routers/api/v1/org/action.go @@ -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 diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index 61c2b6e3fe..01ad2cc5aa 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -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 diff --git a/routers/api/v1/shared/runners.go b/routers/api/v1/shared/runners.go index 2e77cff1d0..ecfbbcda69 100644 --- a/routers/api/v1/shared/runners.go +++ b/routers/api/v1/shared/runners.go @@ -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 diff --git a/routers/api/v1/swagger/action.go b/routers/api/v1/swagger/action.go index bd55f59614..6f95975213 100644 --- a/routers/api/v1/swagger/action.go +++ b/routers/api/v1/swagger/action.go @@ -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"` +} diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index 4860f10c98..1cc77b319a 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -239,4 +239,7 @@ type swaggerParameterBodies struct { // in:body NoteOptions api.NoteOptions + + // in:body + RegisterRunnerOptions api.RegisterRunnerOptions } diff --git a/routers/api/v1/user/runners.go b/routers/api/v1/user/runners.go index 15b138083c..340233f27d 100644 --- a/routers/api/v1/user/runners.go +++ b/routers/api/v1/user/runners.go @@ -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 diff --git a/services/actions/interface.go b/services/actions/interface.go index 466f8ef857..e444eec326 100644 --- a/services/actions/interface.go +++ b/services/actions/interface.go @@ -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) } diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 4a69a39292..28b9c43c37 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -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": { diff --git a/tests/integration/api_admin_actions_test.go b/tests/integration/api_admin_actions_test.go index d9a6a5d6e1..10e4660caa 100644 --- a/tests/integration/api_admin_actions_test.go +++ b/tests/integration/api_admin_actions_test.go @@ -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, ®isterRunnerResponse) + + 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, ®isterRunnerResponse) + + 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) + }) } diff --git a/tests/integration/api_org_actions_test.go b/tests/integration/api_org_actions_test.go index 46f08f4624..c29798d03c 100644 --- a/tests/integration/api_org_actions_test.go +++ b/tests/integration/api_org_actions_test.go @@ -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, ®isterRunnerResponse) + + 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, ®isterRunnerResponse) + + 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) + }) } diff --git a/tests/integration/api_repo_actions_test.go b/tests/integration/api_repo_actions_test.go index 6692ddaa63..d12691b117 100644 --- a/tests/integration/api_repo_actions_test.go +++ b/tests/integration/api_repo_actions_test.go @@ -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, ®isterRunnerResponse) + + 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, ®isterRunnerResponse) + + 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) + }) } diff --git a/tests/integration/api_user_actions_test.go b/tests/integration/api_user_actions_test.go index 106c99bdbe..73a4060c0c 100644 --- a/tests/integration/api_user_actions_test.go +++ b/tests/integration/api_user_actions_test.go @@ -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, ®isterRunnerResponse) + + 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, ®isterRunnerResponse) + + 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) + }) }