diff --git a/.gitleaksignore b/.gitleaksignore index eab7926138c2b6a10fa59c2131d53976a87330c8..56652ab4dae39461372ca0edc4c8750fc870ca06 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -1,2 +1,3 @@ 7e07fe42d34916b276a7b068f4faa8bdc0ebc984:doc/architecture/blueprints/runner_tokens/index.md:gitlab-rrt:485 f6504b498548380198ad38295d9caa71412115f0:doc/architecture/blueprints/runner_tokens/index.md:generic-api-key:506 +afedb913baf4203aa688421873fdb9f94649578e:doc/api/users.md:generic-api-key:2201 diff --git a/app/services/ci/runners/create_runner_service.rb b/app/services/ci/runners/create_runner_service.rb index fcb664500a9b588ad3a3251c3d2e2ece747241e0..ff4a33e431be2cb73da09d7c5d454200d862bc55 100644 --- a/app/services/ci/runners/create_runner_service.rb +++ b/app/services/ci/runners/create_runner_service.rb @@ -29,7 +29,7 @@ def execute return ServiceResponse.success(payload: { runner: runner }) if runner.save - ServiceResponse.error(message: runner.errors.full_messages) + ServiceResponse.error(message: runner.errors.full_messages, reason: :save_error) end def normalize_params diff --git a/doc/api/runners.md b/doc/api/runners.md index 1a722163c4189ece9ec1cc91637e60d7ba0fde84..8d0be1c3aba35f2d6785fe60748663f3077cabea 100644 --- a/doc/api/runners.md +++ b/doc/api/runners.md @@ -273,10 +273,10 @@ PUT /runners/:id | `id` | integer | yes | The ID of a runner | | `description` | string | no | The description of the runner | | `active` | boolean | no | Deprecated: Use `paused` instead. Flag indicating whether the runner is allowed to receive jobs | -| `paused` | boolean | no | Specifies whether the runner should ignore new jobs | +| `paused` | boolean | no | Specifies if the runner should ignore new jobs | | `tag_list` | array | no | The list of tags for the runner | -| `run_untagged` | boolean | no | Specifies whether the runner can execute untagged jobs | -| `locked` | boolean | no | Specifies whether the runner is locked | +| `run_untagged` | boolean | no | Specifies if the runner can execute untagged jobs | +| `locked` | boolean | no | Specifies if the runner is locked | | `access_level` | string | no | The access level of the runner; `not_protected` or `ref_protected` | | `maximum_timeout` | integer | no | Maximum timeout that limits the amount of time (in seconds) that runners can run jobs | @@ -656,20 +656,20 @@ Register a new runner for the instance. POST /runners ``` -| Attribute | Type | Required | Description | -|--------------------|--------------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `token` | string | yes | [Registration token](#registration-and-authentication-tokens) | -| `description` | string | no | Runner's description | +| Attribute | Type | Required | Description | +|--------------------|--------------|----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `token` | string | yes | [Registration token](#registration-and-authentication-tokens) | +| `description` | string | no | Description of the runner | | `info` | hash | no | Runner's metadata. You can include `name`, `version`, `revision`, `platform`, and `architecture`, but only `version`, `platform`, and `architecture` are displayed in the Admin Area of the UI | -| `active` | boolean | no | Deprecated: Use `paused` instead. Specifies whether the runner is allowed to receive new jobs | -| `paused` | boolean | no | Specifies whether the runner should ignore new jobs | -| `locked` | boolean | no | Specifies whether the runner should be locked for the current project | -| `run_untagged` | boolean | no | Specifies whether the runner should handle untagged jobs | -| `tag_list` | string array | no | A list of runner tags | -| `access_level` | string | no | The access level of the runner; `not_protected` or `ref_protected` | -| `maximum_timeout` | integer | no | Maximum timeout that limits the amount of time (in seconds) that runners can run jobs | -| `maintainer_note` | string | no | [Deprecated](https://gitlab.com/gitlab-org/gitlab/-/issues/350730), see `maintenance_note` | -| `maintenance_note` | string | no | Free-form maintenance notes for the runner (1024 characters) | +| `active` | boolean | no | Deprecated: Use `paused` instead. Specifies if the runner is allowed to receive new jobs | +| `paused` | boolean | no | Specifies if the runner should ignore new jobs | +| `locked` | boolean | no | Specifies if the runner should be locked for the current project | +| `run_untagged` | boolean | no | Specifies if the runner should handle untagged jobs | +| `tag_list` | string array | no | A list of runner tags | +| `access_level` | string | no | The access level of the runner; `not_protected` or `ref_protected` | +| `maximum_timeout` | integer | no | Maximum timeout that limits the amount of time (in seconds) that runners can run jobs | +| `maintainer_note` | string | no | [Deprecated](https://gitlab.com/gitlab-org/gitlab/-/issues/350730), see `maintenance_note` | +| `maintenance_note` | string | no | Free-form maintenance notes for the runner (1024 characters) | ```shell curl --request POST "https://gitlab.example.com/api/v4/runners" \ diff --git a/doc/api/users.md b/doc/api/users.md index dba1c30f7e89b6f686d88e94be23bccdc9986b59..bb261cea682984a5941ef1d737305ed8b60042d4 100644 --- a/doc/api/users.md +++ b/doc/api/users.md @@ -2196,3 +2196,42 @@ Returns: - `400 Bad request` if two factor authentication is not enabled for the specified user. - `403 Forbidden` if not authenticated as an administrator. - `404 User Not Found` if user cannot be found. + +## Create a CI runner **(FREE SELF)** + +It creates a new runner, linked to the current user. + +Requires administrator access or ownership of the target namespace or project. Token values are returned once. Make sure you save it because you can't access +it again. + +```plaintext +POST /user/runners +``` + +| Attribute | Type | Required | Description | +|--------------------|--------------|----------|-----------------------------------------------------------------------------------------------------------------------------------------| +| `runner_type` | string | yes | Specifies the scope of the runner; `instance_type`, `group_type`, or `project_type`. | +| `namespace_id` | integer | no | The ID of the project or group that the runner is created in. Required if `runner_type` is `group_type` or `project_type`. | +| `description` | string | no | Description of the runner. | +| `paused` | boolean | no | Specifies if the runner should ignore new jobs. | +| `locked` | boolean | no | Specifies if the runner should be locked for the current project. | +| `run_untagged` | boolean | no | Specifies if the runner should handle untagged jobs. | +| `tag_list` | string array | no | A list of runner tags. | +| `access_level` | string | no | The access level of the runner; `not_protected` or `ref_protected`. | +| `maximum_timeout` | integer | no | Maximum timeout that limits the amount of time (in seconds) that runners can run jobs. | +| `maintenance_note` | string | no | Free-form maintenance notes for the runner (1024 characters). | + +```shell +curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" --data "runner_type=instance_type" \ + "https://gitlab.example.com/api/v4/user/runners" +``` + +Example response: + +```json +{ + "id": 9171, + "token": "glrt-kyahzxLaj4Dc1jQf4xjX", + "token_expires_at": null +} +``` diff --git a/lib/api/ci/runner.rb b/lib/api/ci/runner.rb index 1ecd498c6a830965dd741fb81ff01a6525778e72..d61171ea9f4c0dade23b0eda2ab6706904a3ffba 100644 --- a/lib/api/ci/runner.rb +++ b/lib/api/ci/runner.rb @@ -15,7 +15,7 @@ class Runner < ::API::Base end params do requires :token, type: String, desc: 'Registration token' - optional :description, type: String, desc: %q(Runner's description) + optional :description, type: String, desc: %q(Description of the runner) optional :maintainer_note, type: String, desc: %q(Deprecated: see `maintenance_note`) optional :maintenance_note, type: String, desc: %q(Free-form maintenance notes for the runner (1024 characters)) @@ -27,13 +27,13 @@ class Runner < ::API::Base optional :architecture, type: String, desc: %q(Runner's architecture) end optional :active, type: Boolean, - desc: 'Deprecated: Use `paused` instead. Specifies whether the runner is allowed ' \ + desc: 'Deprecated: Use `paused` instead. Specifies if the runner is allowed ' \ 'to receive new jobs' - optional :paused, type: Boolean, desc: 'Specifies whether the runner should ignore new jobs' - optional :locked, type: Boolean, desc: 'Specifies whether the runner should be locked for the current project' + optional :paused, type: Boolean, desc: 'Specifies if the runner should ignore new jobs' + optional :locked, type: Boolean, desc: 'Specifies if the runner should be locked for the current project' optional :access_level, type: String, values: ::Ci::Runner.access_levels.keys, desc: 'The access level of the runner' - optional :run_untagged, type: Boolean, desc: 'Specifies whether the runner should handle untagged jobs' + optional :run_untagged, type: Boolean, desc: 'Specifies if the runner should handle untagged jobs' optional :tag_list, type: Array[String], coerce_with: ::API::Validations::Types::CommaSeparatedToArray.coerce, desc: %q(A list of runner tags) optional :maximum_timeout, type: Integer, diff --git a/lib/api/ci/runners.rb b/lib/api/ci/runners.rb index f2f0f32261af05c09bc7468a9423b84f8bd4971b..42817c782f486a10e07343399b8b1e11cda45ca5 100644 --- a/lib/api/ci/runners.rb +++ b/lib/api/ci/runners.rb @@ -158,11 +158,11 @@ def authenticate_list_runners_jobs!(runner) requires :id, type: Integer, desc: 'The ID of a runner' optional :description, type: String, desc: 'The description of the runner' optional :active, type: Boolean, desc: 'Deprecated: Use `paused` instead. Flag indicating whether the runner is allowed to receive jobs' - optional :paused, type: Boolean, desc: 'Specifies whether the runner should ignore new jobs' + optional :paused, type: Boolean, desc: 'Specifies if the runner should ignore new jobs' optional :tag_list, type: Array[String], coerce_with: ::API::Validations::Types::CommaSeparatedToArray.coerce, desc: 'The list of tags for a runner', documentation: { example: "['macos', 'shell']" } - optional :run_untagged, type: Boolean, desc: 'Specifies whether the runner can execute untagged jobs' - optional :locked, type: Boolean, desc: 'Specifies whether the runner is locked' + optional :run_untagged, type: Boolean, desc: 'Specifies if the runner can execute untagged jobs' + optional :locked, type: Boolean, desc: 'Specifies if the runner is locked' optional :access_level, type: String, values: ::Ci::Runner.access_levels.keys, desc: 'The access level of the runner' optional :maximum_timeout, type: Integer, diff --git a/lib/api/users.rb b/lib/api/users.rb index 0c118767bc415a20ac71401791ad4646b2c72752..8d34be362f44873c7113776419163edbb685f645 100644 --- a/lib/api/users.rb +++ b/lib/api/users.rb @@ -1365,6 +1365,61 @@ def set_user_status(include_missing_params:) get 'status', feature_category: :user_profile do present current_user.status || {}, with: Entities::UserStatus end + + desc 'Create a runner owned by currently authenticated user' do + detail 'Create a new runner' + success Entities::Ci::RunnerRegistrationDetails + failure [[400, 'Bad Request'], [403, 'Forbidden']] + tags %w[user runners] + end + params do + requires :runner_type, type: String, values: ::Ci::Runner.runner_types.keys, + desc: %q(Specifies the scope of the runner) + given runner_type: ->(runner_type) { %i[group_type project_type].include? runner_type } do + requires :namespace_id, type: Integer, + desc: 'The ID of the project or group that the runner is created in', + documentation: { example: 1 } + end + optional :description, type: String, desc: %q(Description of the runner) + optional :maintenance_note, type: String, + desc: %q(Free-form maintenance notes for the runner (1024 characters)) + optional :paused, type: Boolean, desc: 'Specifies if the runner should ignore new jobs (defaults to false)' + optional :locked, type: Boolean, + desc: 'Specifies if the runner should be locked for the current project (defaults to false)' + optional :access_level, type: String, values: ::Ci::Runner.access_levels.keys, + desc: 'The access level of the runner' + optional :run_untagged, type: Boolean, + desc: 'Specifies if the runner should handle untagged jobs (defaults to true)' + optional :tag_list, type: Array[String], coerce_with: ::API::Validations::Types::CommaSeparatedToArray.coerce, + desc: %q(A list of runner tags) + optional :maximum_timeout, type: Integer, + desc: 'Maximum timeout that limits the amount of time (in seconds) that runners can run jobs' + end + post 'runners', urgency: :low, feature_category: :runner_fleet do + attributes = attributes_for_keys( + %i[runner_type namespace_id description maintenance_note paused locked run_untagged tag_list + access_level maximum_timeout] + ) + + namespace_id = attributes.delete(:namespace_id) + if namespace_id + case attributes[:runner_type] + when 'group_type' + attributes[:scope] = ::Group.find(namespace_id) + when 'project_type' + attributes[:scope] = ::Project.find(namespace_id) + end + end + + result = ::Ci::Runners::CreateRunnerService.new(user: current_user, params: attributes).execute + if result.error? + message = result.errors.to_sentence + forbidden!(message) if result.reason == :forbidden + bad_request!(message) + end + + present result.payload[:runner], with: Entities::Ci::RunnerRegistrationDetails + end end end end diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb index 6ff5cd7e100f0d85c21a5ec6819f7845b5b6e345..522452656bce0ccf0d8a91e64d4aab3e4f15112f 100644 --- a/spec/requests/api/users_spec.rb +++ b/spec/requests/api/users_spec.rb @@ -4632,4 +4632,158 @@ def update_password(user, admin, password = User.random_password) let(:attributable) { user } let(:other_attributable) { admin } end + + describe 'POST /user/runners', feature_category: :runner_fleet do + subject(:request) { post api('/user/runners', current_user, **post_args), params: runner_attrs } + + let_it_be(:group_owner) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, namespace: group) } + + let(:post_args) { { admin_mode: true } } + let(:runner_attrs) { { runner_type: 'instance_type' } } + + before do + group.add_owner(group_owner) + end + + shared_context 'returns forbidden when user does not have sufficient permissions' do + let(:current_user) { admin } + let(:post_args) { { admin_mode: false } } + + it 'does not create a runner' do + expect do + request + + expect(response).to have_gitlab_http_status(:forbidden) + end.not_to change { Ci::Runner.count } + end + end + + shared_examples 'creates a runner' do + it 'creates a runner' do + expect do + request + + expect(response).to have_gitlab_http_status(:created) + end.to change { Ci::Runner.count }.by(1) + end + end + + shared_examples 'fails to create runner with :bad_request' do + it 'does not create runner' do + expect do + request + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['message']).to include(expected_error) + end.not_to change { Ci::Runner.count } + end + end + + context 'when runner_type is :instance_type' do + let(:runner_attrs) { { runner_type: 'instance_type' } } + + context 'when user has sufficient permissions' do + let(:current_user) { admin } + + it_behaves_like 'creates a runner' + end + + it_behaves_like 'returns forbidden when user does not have sufficient permissions' + + context 'when model validation fails' do + let(:runner_attrs) { { runner_type: 'instance_type', run_untagged: false, tag_list: [] } } + let(:current_user) { admin } + + it_behaves_like 'fails to create runner with :bad_request' do + let(:expected_error) { 'Tags list can not be empty' } + end + end + end + + context 'when runner_type is :group_type' do + let(:post_args) { {} } + + context 'when namespace_id is specified' do + let(:runner_attrs) { { runner_type: 'group_type', namespace_id: group.id } } + + context 'when user has sufficient permissions' do + let(:current_user) { group_owner } + + it_behaves_like 'creates a runner' + end + + it_behaves_like 'returns forbidden when user does not have sufficient permissions' + end + + context 'when namespace_id is not specified' do + let(:runner_attrs) { { runner_type: 'group_type' } } + let(:current_user) { group_owner } + + it_behaves_like 'fails to create runner with :bad_request' do + let(:expected_error) { 'Missing/invalid scope' } + end + end + end + + context 'when runner_type is :project_type' do + let(:post_args) { {} } + + context 'when namespace_id is specified' do + let(:runner_attrs) { { runner_type: 'project_type', namespace_id: project.id } } + + context 'when user has sufficient permissions' do + let(:current_user) { group_owner } + + it_behaves_like 'creates a runner' + end + + it_behaves_like 'returns forbidden when user does not have sufficient permissions' + end + + context 'when namespace_id is not specified' do + let(:runner_attrs) { { runner_type: 'project_type' } } + let(:current_user) { group_owner } + + it_behaves_like 'fails to create runner with :bad_request' do + let(:expected_error) { 'Missing/invalid scope' } + end + end + end + + context 'with missing runner_type' do + let(:runner_attrs) { {} } + let(:current_user) { admin } + + it 'fails to create runner with :bad_request' do + expect do + request + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['error']).to eq('runner_type is missing, runner_type does not have a valid value') + end.not_to change { Ci::Runner.count } + end + end + + context 'with unknown runner_type' do + let(:runner_attrs) { { runner_type: 'unknown' } } + let(:current_user) { admin } + + it 'fails to create runner with :bad_request' do + expect do + request + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['error']).to eq('runner_type does not have a valid value') + end.not_to change { Ci::Runner.count } + end + end + + it 'returns a 401 error if unauthorized' do + post api('/user/runners'), params: runner_attrs + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end end diff --git a/spec/services/ci/runners/create_runner_service_spec.rb b/spec/services/ci/runners/create_runner_service_spec.rb index 886411ec5fb2b6a70acb889f83c5b6a9f9952fbe..db337b0b005f292d0323af63d4a1657ac9d5638d 100644 --- a/spec/services/ci/runners/create_runner_service_spec.rb +++ b/spec/services/ci/runners/create_runner_service_spec.rb @@ -179,6 +179,17 @@ it_behaves_like 'it cannot create a runner' end + + context 'when model validation fails' do + let(:params) { { runner_type: 'instance_type', run_untagged: false, tag_list: [] } } + + it_behaves_like 'it cannot create a runner' + + it 'returns error message and reason', :aggregate_failures do + expect(execute.reason).to eq(:save_error) + expect(execute.message).to contain_exactly(a_string_including('Tags list can not be empty')) + end + end end end end