diff options
author | Kamil Trzciński <ayufan@ayufan.eu> | 2016-02-19 13:23:28 +0000 |
---|---|---|
committer | Kamil Trzciński <ayufan@ayufan.eu> | 2016-02-19 13:23:28 +0000 |
commit | 1817b766b2bdf03e886118bda5e1aee48b5c2413 (patch) | |
tree | 9d196782ef6d12a48f2956ada9320181a485d3d4 | |
parent | c21c8825d812d0fecd887090fbb4377cc7ee2f97 (diff) | |
parent | f2f6f77328fab308df5474a044e3205b6bd611c3 (diff) | |
download | gitlab-ce-1817b766b2bdf03e886118bda5e1aee48b5c2413.tar.gz |
Merge branch 'ci/api-runners' into 'master'
Add runners API
References #4264
See merge request !2640
-rw-r--r-- | CHANGELOG | 1 | ||||
-rw-r--r-- | app/models/ci/runner.rb | 6 | ||||
-rw-r--r-- | doc/api/README.md | 1 | ||||
-rw-r--r-- | doc/api/runners.md | 318 | ||||
-rw-r--r-- | doc/ci/api/runners.md | 6 | ||||
-rw-r--r-- | lib/api/api.rb | 1 | ||||
-rw-r--r-- | lib/api/entities.rb | 18 | ||||
-rw-r--r-- | lib/api/runners.rb | 175 | ||||
-rw-r--r-- | spec/factories/ci/runners.rb | 10 | ||||
-rw-r--r-- | spec/features/runners_spec.rb | 10 | ||||
-rw-r--r-- | spec/models/build_spec.rb | 6 | ||||
-rw-r--r-- | spec/models/ci/runner_spec.rb | 14 | ||||
-rw-r--r-- | spec/models/project_spec.rb | 4 | ||||
-rw-r--r-- | spec/requests/api/runners_spec.rb | 464 |
14 files changed, 1008 insertions, 26 deletions
diff --git a/CHANGELOG b/CHANGELOG index 4fe250efd42..93a5203c60f 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -65,6 +65,7 @@ v 8.5.0 (unreleased) - Ability to see and sort on vote count from Issues and MR lists - Fix builds scheduler when first build in stage was allowed to fail - User project limit is reached notice is hidden if the projects limit is zero + - Add API support for managing runners and project's runners v 8.4.4 - Update omniauth-saml gem to 1.4.2 diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 38b20cd7faa..e725a6d468c 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -22,6 +22,7 @@ module Ci extend Ci::Model LAST_CONTACT_TIME = 5.minutes.ago + AVAILABLE_SCOPES = ['specific', 'shared', 'active', 'paused', 'online'] has_many :builds, class_name: 'Ci::Build' has_many :runner_projects, dependent: :destroy, class_name: 'Ci::RunnerProject' @@ -38,6 +39,11 @@ module Ci scope :online, ->() { where('contacted_at > ?', LAST_CONTACT_TIME) } scope :ordered, ->() { order(id: :desc) } + scope :owned_or_shared, ->(project_id) do + joins('LEFT JOIN ci_runner_projects ON ci_runner_projects.runner_id = ci_runners.id') + .where("ci_runner_projects.gl_project_id = :project_id OR ci_runners.is_shared = true", project_id: project_id) + end + acts_as_taggable def self.search(query) diff --git a/doc/api/README.md b/doc/api/README.md index 9f3ad126320..7629ef294ac 100644 --- a/doc/api/README.md +++ b/doc/api/README.md @@ -32,6 +32,7 @@ following locations: - [Builds](builds.md) - [Build triggers](build_triggers.md) - [Build Variables](build_variables.md) +- [Runners](runners.md) ## Authentication diff --git a/doc/api/runners.md b/doc/api/runners.md new file mode 100644 index 00000000000..8840b8abdcc --- /dev/null +++ b/doc/api/runners.md @@ -0,0 +1,318 @@ +# Runners API + +## List owned runners + +Get a list of specific runners available to the user. + +``` +GET /runners +GET /runners?scope=active +``` + +| Attribute | Type | Required | Description | +|-----------|---------|----------|---------------------| +| `scope` | string | no | The scope of specific runners to show, one of: `active`, `paused`, `online`; showing all runners if none provided | + +``` +curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/runners" +``` + +Example response: + +```json +[ + { + "active": true, + "description": "test-1-20150125", + "id": 6, + "is_shared": false, + "name": null + }, + { + "active": true, + "description": "test-2-20150125", + "id": 8, + "is_shared": false, + "name": null + } +] +``` + +## List all runners + +Get a list of all runners in the GitLab instance (specific and shared). Access +is restricted to users with `admin` privileges. + +``` +GET /runners/all +GET /runners/all?scope=online +``` + +| Attribute | Type | Required | Description | +|-----------|---------|----------|---------------------| +| `scope` | string | no | The scope of runners to show, one of: `specific`, `shared`, `active`, `paused`, `online`; showing all runners if none provided | + +``` +curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/runners/all" +``` + +Example response: + +```json +[ + { + "active": true, + "description": "shared-runner-1", + "id": 1, + "is_shared": true, + "name": null + }, + { + "active": true, + "description": "shared-runner-2", + "id": 3, + "is_shared": true, + "name": null + }, + { + "active": true, + "description": "test-1-20150125", + "id": 6, + "is_shared": false, + "name": null + }, + { + "active": true, + "description": "test-2-20150125", + "id": 8, + "is_shared": false, + "name": null + } +] +``` + +## Get runner's details + +Get details of a runner. + +``` +GET /runners/:id +``` + +| Attribute | Type | Required | Description | +|-----------|---------|----------|---------------------| +| `id` | integer | yes | The ID of a runner | + +``` +curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/runners/6" +``` + +Example response: + +```json +{ + "active": true, + "architecture": null, + "description": "test-1-20150125", + "id": 6, + "is_shared": false, + "contacted_at": "2016-01-25T16:39:48.066Z", + "name": null, + "platform": null, + "projects": [ + { + "id": 1, + "name": "GitLab Community Edition", + "name_with_namespace": "GitLab.org / GitLab Community Edition", + "path": "gitlab-ce", + "path_with_namespace": "gitlab-org/gitlab-ce" + } + ], + "token": "205086a8e3b9a2b818ffac9b89d102", + "revision": null, + "tag_list": [ + "ruby", + "mysql" + ], + "version": null +} +``` + +## Update runner's details + +Update details of a runner. + +``` +PUT /runners/:id +``` + +| Attribute | Type | Required | Description | +|---------------|---------|----------|---------------------| +| `id` | integer | yes | The ID of a runner | +| `description` | string | no | The description of a runner | +| `active` | boolean | no | The state of a runner; can be set to `true` or `false` | +| `tag_list` | array | no | The list of tags for a runner; put array of tags, that should be finally assigned to a runner | + +``` +curl -X PUT -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/runners/6" -F "description=test-1-20150125-test" -F "tag_list=ruby,mysql,tag1,tag2" +``` + +Example response: + +```json +{ + "active": true, + "architecture": null, + "description": "test-1-20150125-test", + "id": 6, + "is_shared": false, + "contacted_at": "2016-01-25T16:39:48.066Z", + "name": null, + "platform": null, + "projects": [ + { + "id": 1, + "name": "GitLab Community Edition", + "name_with_namespace": "GitLab.org / GitLab Community Edition", + "path": "gitlab-ce", + "path_with_namespace": "gitlab-org/gitlab-ce" + } + ], + "token": "205086a8e3b9a2b818ffac9b89d102", + "revision": null, + "tag_list": [ + "ruby", + "mysql", + "tag1", + "tag2" + ], + "version": null +} +``` + +## Remove a runner + +Remove a runner. + +``` +DELETE /runners/:id +``` + +| Attribute | Type | Required | Description | +|-----------|---------|----------|---------------------| +| `id` | integer | yes | The ID of a runner | + +``` +curl -X DELETE -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/runners/6" +``` + +Example response: + +```json +{ + "active": true, + "description": "test-1-20150125-test", + "id": 6, + "is_shared": false, + "name": null, +} +``` + +## List project's runners + +List all runners (specific and shared) available in the project. Shared runners +are listed if at least one shared runner is defined **and** shared runners +usage is enabled in the project's settings. + +``` +GET /projects/:id/runners +``` + +| Attribute | Type | Required | Description | +|-----------|---------|----------|---------------------| +| `id` | integer | yes | The ID of a project | + +``` +curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/9/runners" +``` + +Example response: + +```json +[ + { + "active": true, + "description": "test-2-20150125", + "id": 8, + "is_shared": false, + "name": null + }, + { + "active": true, + "description": "development_runner", + "id": 5, + "is_shared": true, + "name": null + } +] +``` + +## Enable a runner in project + +Enable an available specific runner in the project. + +``` +POST /projects/:id/runners +``` + +| Attribute | Type | Required | Description | +|-------------|---------|----------|---------------------| +| `id` | integer | yes | The ID of a project | +| `runner_id` | integer | yes | The ID of a runner | + +``` +curl -X POST -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/project/9/runners" -F "runner_id=9" +``` + +Example response: + +```json +{ + "active": true, + "description": "test-2016-02-01", + "id": 9, + "is_shared": false, + "name": null +} +``` + +## Disable a runner from project + +Disable a specific runner from the project. It works only if the project isn't +the only project associated with the specified runner. If so, an error is +returned. Use the [Remove a runner](#remove-a-runner) call instead. + +``` +DELETE /projects/:id/runners/:runner_id +``` + +| Attribute | Type | Required | Description | +|-------------|---------|----------|---------------------| +| `id` | integer | yes | The ID of a project | +| `runner_id` | integer | yes | The ID of a runner | + +``` +curl -X DELETE -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/project/9/runners/9" +``` + +Example response: + +```json +{ + "active": true, + "description": "test-2016-02-01", + "id": 9, + "is_shared": false, + "name": null +} +``` diff --git a/doc/ci/api/runners.md b/doc/ci/api/runners.md index c383dc4bcc9..e9033aeacd5 100644 --- a/doc/ci/api/runners.md +++ b/doc/ci/api/runners.md @@ -1,5 +1,9 @@ # Runners API +_**Note:** This API is intended to be used only by Runners as their own +communication channel. For the consumer API see the +[new Runners API](../../api/runners.md)._ + ## Runners ### Retrieve all runners @@ -74,4 +78,4 @@ Returns: "updated_at" : "2015-02-26T11:39:39.232Z", "description" : "awesome runner" } -```
\ No newline at end of file +``` diff --git a/lib/api/api.rb b/lib/api/api.rb index 7efe0a0262f..7d65145176b 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -56,5 +56,6 @@ module API mount Triggers mount Builds mount Variables + mount Runners end end diff --git a/lib/api/entities.rb b/lib/api/entities.rb index a9c09ffdb31..857705dbf12 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -49,7 +49,7 @@ module API expose :enable_ssl_verification end - class ForkedFromProject < Grape::Entity + class BasicProjectDetails < Grape::Entity expose :id expose :name, :name_with_namespace expose :path, :path_with_namespace @@ -67,7 +67,7 @@ module API expose :shared_runners_enabled expose :creator_id expose :namespace - expose :forked_from_project, using: Entities::ForkedFromProject, if: lambda{ |project, options| project.forked? } + expose :forked_from_project, using: Entities::BasicProjectDetails, if: lambda{ |project, options| project.forked? } expose :avatar_url expose :star_count, :forks_count expose :open_issues_count, if: lambda { |project, options| project.issues_enabled? && project.default_issues_tracker? } @@ -377,6 +377,20 @@ module API expose :name end + class RunnerDetails < Runner + expose :tag_list + expose :version, :revision, :platform, :architecture + expose :contacted_at + expose :token, if: lambda { |runner, options| options[:current_user].is_admin? || !runner.is_shared? } + expose :projects, with: Entities::BasicProjectDetails do |runner, options| + if options[:current_user].is_admin? + runner.projects + else + options[:current_user].authorized_projects.where(id: runner.projects) + end + end + end + class Build < Grape::Entity expose :id, :status, :stage, :name, :ref, :tag, :coverage expose :created_at, :started_at, :finished_at diff --git a/lib/api/runners.rb b/lib/api/runners.rb new file mode 100644 index 00000000000..8ec91485b26 --- /dev/null +++ b/lib/api/runners.rb @@ -0,0 +1,175 @@ +module API + # Runners API + class Runners < Grape::API + before { authenticate! } + + resource :runners do + # Get runners available for user + # + # Example Request: + # GET /runners + get do + runners = filter_runners(current_user.ci_authorized_runners, params[:scope], without: ['specific', 'shared']) + present paginate(runners), with: Entities::Runner + end + + # Get all runners - shared and specific + # + # Example Request: + # GET /runners/all + get 'all' do + authenticated_as_admin! + runners = filter_runners(Ci::Runner.all, params[:scope]) + present paginate(runners), with: Entities::Runner + end + + # Get runner's details + # + # Parameters: + # id (required) - The ID of ther runner + # Example Request: + # GET /runners/:id + get ':id' do + runner = get_runner(params[:id]) + authenticate_show_runner!(runner) + + present runner, with: Entities::RunnerDetails, current_user: current_user + end + + # Update runner's details + # + # Parameters: + # id (required) - The ID of ther runner + # description (optional) - Runner's description + # active (optional) - Runner's status + # tag_list (optional) - Array of tags for runner + # Example Request: + # PUT /runners/:id + put ':id' do + runner = get_runner(params[:id]) + authenticate_update_runner!(runner) + + attrs = attributes_for_keys [:description, :active, :tag_list] + if runner.update(attrs) + present runner, with: Entities::RunnerDetails, current_user: current_user + else + render_validation_error!(runner) + end + end + + # Remove runner + # + # Parameters: + # id (required) - The ID of ther runner + # Example Request: + # DELETE /runners/:id + delete ':id' do + runner = get_runner(params[:id]) + authenticate_delete_runner!(runner) + runner.destroy! + + present runner, with: Entities::Runner + end + end + + resource :projects do + before { authorize_admin_project } + + # Get runners available for project + # + # Example Request: + # GET /projects/:id/runners + get ':id/runners' do + runners = filter_runners(Ci::Runner.owned_or_shared(user_project.id), params[:scope]) + present paginate(runners), with: Entities::Runner + end + + # Enable runner for project + # + # Parameters: + # id (required) - The ID of the project + # runner_id (required) - The ID of the runner + # Example Request: + # POST /projects/:id/runners/:runner_id + post ':id/runners' do + required_attributes! [:runner_id] + + runner = get_runner(params[:runner_id]) + authenticate_enable_runner!(runner) + Ci::RunnerProject.create(runner: runner, project: user_project) + + present runner, with: Entities::Runner + end + + # Disable project's runner + # + # Parameters: + # id (required) - The ID of the project + # runner_id (required) - The ID of the runner + # Example Request: + # DELETE /projects/:id/runners/:runner_id + delete ':id/runners/:runner_id' do + runner_project = user_project.runner_projects.find_by(runner_id: params[:runner_id]) + not_found!('Runner') unless runner_project + + runner = runner_project.runner + forbidden!("Only one project associated with the runner. Please remove the runner instead") if runner.projects.count == 1 + + runner_project.destroy + + present runner, with: Entities::Runner + end + end + + helpers do + def filter_runners(runners, scope, options = {}) + return runners unless scope.present? + + available_scopes = ::Ci::Runner::AVAILABLE_SCOPES + if options[:without] + available_scopes = available_scopes - options[:without] + end + + if (available_scopes & [scope]).empty? + render_api_error!('Scope contains invalid value', 400) + end + + runners.send(scope) + end + + def get_runner(id) + runner = Ci::Runner.find(id) + not_found!('Runner') unless runner + runner + end + + def authenticate_show_runner!(runner) + return if runner.is_shared || current_user.is_admin? + forbidden!("No access granted") unless user_can_access_runner?(runner) + end + + def authenticate_update_runner!(runner) + return if current_user.is_admin? + forbidden!("Runner is shared") if runner.is_shared? + forbidden!("No access granted") unless user_can_access_runner?(runner) + end + + def authenticate_delete_runner!(runner) + return if current_user.is_admin? + forbidden!("Runner is shared") if runner.is_shared? + forbidden!("Runner associated with more than one project") if runner.projects.count > 1 + forbidden!("No access granted") unless user_can_access_runner?(runner) + end + + def authenticate_enable_runner!(runner) + forbidden!("Runner is shared") if runner.is_shared? + return if current_user.is_admin? + forbidden!("No access granted") unless user_can_access_runner?(runner) + end + + def user_can_access_runner?(runner) + current_user.ci_authorized_runners.exists?(runner.id) + end + end + end +end diff --git a/spec/factories/ci/runners.rb b/spec/factories/ci/runners.rb index db759eca9ac..265663e8453 100644 --- a/spec/factories/ci/runners.rb +++ b/spec/factories/ci/runners.rb @@ -25,14 +25,12 @@ FactoryGirl.define do "My runner#{n}" end - platform "darwin" + platform "darwin" + is_shared false + active true - factory :ci_shared_runner do + trait :shared do is_shared true end - - factory :ci_specific_runner do - is_shared false - end end end diff --git a/spec/features/runners_spec.rb b/spec/features/runners_spec.rb index d97831aae14..e8886e7edf9 100644 --- a/spec/features/runners_spec.rb +++ b/spec/features/runners_spec.rb @@ -17,10 +17,10 @@ describe "Runners" do @project3 = FactoryGirl.create :empty_project @project3.team << [user, :developer] - @shared_runner = FactoryGirl.create :ci_shared_runner - @specific_runner = FactoryGirl.create :ci_specific_runner - @specific_runner2 = FactoryGirl.create :ci_specific_runner - @specific_runner3 = FactoryGirl.create :ci_specific_runner + @shared_runner = FactoryGirl.create :ci_runner, :shared + @specific_runner = FactoryGirl.create :ci_runner + @specific_runner2 = FactoryGirl.create :ci_runner + @specific_runner3 = FactoryGirl.create :ci_runner @project.runners << @specific_runner @project2.runners << @specific_runner2 @project3.runners << @specific_runner3 @@ -84,7 +84,7 @@ describe "Runners" do before do @project = FactoryGirl.create :empty_project @project.team << [user, :master] - @specific_runner = FactoryGirl.create :ci_specific_runner + @specific_runner = FactoryGirl.create :ci_runner @project.runners << @specific_runner end diff --git a/spec/models/build_spec.rb b/spec/models/build_spec.rb index 606340d87e4..0acf6e6588a 100644 --- a/spec/models/build_spec.rb +++ b/spec/models/build_spec.rb @@ -243,7 +243,7 @@ describe Ci::Build, models: true do end describe :can_be_served? do - let(:runner) { FactoryGirl.create :ci_specific_runner } + let(:runner) { FactoryGirl.create :ci_runner } before { build.project.runners << runner } @@ -285,7 +285,7 @@ describe Ci::Build, models: true do end context 'if there are runner' do - let(:runner) { FactoryGirl.create :ci_specific_runner } + let(:runner) { FactoryGirl.create :ci_runner } before do build.project.runners << runner @@ -322,7 +322,7 @@ describe Ci::Build, models: true do it { is_expected.to be_truthy } context "and there are specific runner" do - let(:runner) { FactoryGirl.create :ci_specific_runner, contacted_at: 1.second.ago } + let(:runner) { FactoryGirl.create :ci_runner, contacted_at: 1.second.ago } before do build.project.runners << runner diff --git a/spec/models/ci/runner_spec.rb b/spec/models/ci/runner_spec.rb index 232760dfeba..e891838672e 100644 --- a/spec/models/ci/runner_spec.rb +++ b/spec/models/ci/runner_spec.rb @@ -39,7 +39,7 @@ describe Ci::Runner, models: true do describe :assign_to do let!(:project) { FactoryGirl.create :empty_project } - let!(:shared_runner) { FactoryGirl.create(:ci_shared_runner) } + let!(:shared_runner) { FactoryGirl.create(:ci_runner, :shared) } before { shared_runner.assign_to(project) } @@ -52,15 +52,15 @@ describe Ci::Runner, models: true do subject { Ci::Runner.online } before do - @runner1 = FactoryGirl.create(:ci_shared_runner, contacted_at: 1.year.ago) - @runner2 = FactoryGirl.create(:ci_shared_runner, contacted_at: 1.second.ago) + @runner1 = FactoryGirl.create(:ci_runner, :shared, contacted_at: 1.year.ago) + @runner2 = FactoryGirl.create(:ci_runner, :shared, contacted_at: 1.second.ago) end it { is_expected.to eq([@runner2])} end describe :online? do - let(:runner) { FactoryGirl.create(:ci_shared_runner) } + let(:runner) { FactoryGirl.create(:ci_runner, :shared) } subject { runner.online? } @@ -84,7 +84,7 @@ describe Ci::Runner, models: true do end describe :status do - let(:runner) { FactoryGirl.create(:ci_shared_runner, contacted_at: 1.second.ago) } + let(:runner) { FactoryGirl.create(:ci_runner, :shared, contacted_at: 1.second.ago) } subject { runner.status } @@ -115,7 +115,7 @@ describe Ci::Runner, models: true do describe "belongs_to_one_project?" do it "returns false if there are two projects runner assigned to" do - runner = FactoryGirl.create(:ci_specific_runner) + runner = FactoryGirl.create(:ci_runner) project = FactoryGirl.create(:empty_project) project1 = FactoryGirl.create(:empty_project) project.runners << runner @@ -125,7 +125,7 @@ describe Ci::Runner, models: true do end it "returns true" do - runner = FactoryGirl.create(:ci_specific_runner) + runner = FactoryGirl.create(:ci_runner) project = FactoryGirl.create(:empty_project) project.runners << runner diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index a3de23369e1..3ccb627a259 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -519,8 +519,8 @@ describe Project, models: true do describe :any_runners do let(:project) { create(:empty_project, shared_runners_enabled: shared_runners_enabled) } - let(:specific_runner) { create(:ci_specific_runner) } - let(:shared_runner) { create(:ci_shared_runner) } + let(:specific_runner) { create(:ci_runner) } + let(:shared_runner) { create(:ci_runner, :shared) } context 'for shared runners disabled' do let(:shared_runners_enabled) { false } diff --git a/spec/requests/api/runners_spec.rb b/spec/requests/api/runners_spec.rb new file mode 100644 index 00000000000..78484747d6a --- /dev/null +++ b/spec/requests/api/runners_spec.rb @@ -0,0 +1,464 @@ +require 'spec_helper' + +describe API::Runners, api: true do + include ApiHelpers + + let(:admin) { create(:user, :admin) } + let(:user) { create(:user) } + let(:user2) { create(:user) } + + let(:project) { create(:project, creator_id: user.id) } + let(:project2) { create(:project, creator_id: user.id) } + + let!(:shared_runner) { create(:ci_runner, :shared) } + let!(:unused_specific_runner) { create(:ci_runner) } + + let!(:specific_runner) do + create(:ci_runner).tap do |runner| + create(:ci_runner_project, runner: runner, project: project) + end + end + + let!(:two_projects_runner) do + create(:ci_runner).tap do |runner| + create(:ci_runner_project, runner: runner, project: project) + create(:ci_runner_project, runner: runner, project: project2) + end + end + + before do + # Set project access for users + create(:project_member, user: user, project: project, access_level: ProjectMember::MASTER) + create(:project_member, user: user, project: project2, access_level: ProjectMember::MASTER) + create(:project_member, user: user2, project: project, access_level: ProjectMember::REPORTER) + end + + describe 'GET /runners' do + context 'authorized user' do + it 'should return user available runners' do + get api('/runners', user) + shared = json_response.any?{ |r| r['is_shared'] } + + expect(response.status).to eq(200) + expect(json_response).to be_an Array + expect(shared).to be_falsey + end + + it 'should filter runners by scope' do + get api('/runners?scope=active', user) + shared = json_response.any?{ |r| r['is_shared'] } + + expect(response.status).to eq(200) + expect(json_response).to be_an Array + expect(shared).to be_falsey + end + + it 'should avoid filtering if scope is invalid' do + get api('/runners?scope=unknown', user) + expect(response.status).to eq(400) + end + end + + context 'unauthorized user' do + it 'should not return runners' do + get api('/runners') + + expect(response.status).to eq(401) + end + end + end + + describe 'GET /runners/all' do + context 'authorized user' do + context 'with admin privileges' do + it 'should return all runners' do + get api('/runners/all', admin) + shared = json_response.any?{ |r| r['is_shared'] } + + expect(response.status).to eq(200) + expect(json_response).to be_an Array + expect(shared).to be_truthy + end + end + + context 'without admin privileges' do + it 'should not return runners list' do + get api('/runners/all', user) + + expect(response.status).to eq(403) + end + end + + it 'should filter runners by scope' do + get api('/runners/all?scope=specific', admin) + shared = json_response.any?{ |r| r['is_shared'] } + + expect(response.status).to eq(200) + expect(json_response).to be_an Array + expect(shared).to be_falsey + end + + it 'should avoid filtering if scope is invalid' do + get api('/runners?scope=unknown', admin) + expect(response.status).to eq(400) + end + end + + context 'unauthorized user' do + it 'should not return runners' do + get api('/runners') + + expect(response.status).to eq(401) + end + end + end + + describe 'GET /runners/:id' do + context 'admin user' do + context 'when runner is shared' do + it "should return runner's details" do + get api("/runners/#{shared_runner.id}", admin) + + expect(response.status).to eq(200) + expect(json_response['description']).to eq(shared_runner.description) + end + end + + context 'when runner is not shared' do + it "should return runner's details" do + get api("/runners/#{specific_runner.id}", admin) + + expect(response.status).to eq(200) + expect(json_response['description']).to eq(specific_runner.description) + end + end + + it 'should return 404 if runner does not exists' do + get api('/runners/9999', admin) + + expect(response.status).to eq(404) + end + end + + context "runner project's administrative user" do + context 'when runner is not shared' do + it "should return runner's details" do + get api("/runners/#{specific_runner.id}", user) + + expect(response.status).to eq(200) + expect(json_response['description']).to eq(specific_runner.description) + end + end + + context 'when runner is shared' do + it "should return runner's details" do + get api("/runners/#{shared_runner.id}", user) + + expect(response.status).to eq(200) + expect(json_response['description']).to eq(shared_runner.description) + end + end + end + + context 'other authorized user' do + it "should not return runner's details" do + get api("/runners/#{specific_runner.id}", user2) + + expect(response.status).to eq(403) + end + end + + context 'unauthorized user' do + it "should not return runner's details" do + get api("/runners/#{specific_runner.id}") + + expect(response.status).to eq(401) + end + end + end + + describe 'PUT /runners/:id' do + context 'admin user' do + context 'when runner is shared' do + it 'should update runner' do + description = shared_runner.description + active = shared_runner.active + + put api("/runners/#{shared_runner.id}", admin), description: "#{description}_updated", active: !active, + tag_list: ['ruby2.1', 'pgsql', 'mysql'] + shared_runner.reload + + expect(response.status).to eq(200) + expect(shared_runner.description).to eq("#{description}_updated") + expect(shared_runner.active).to eq(!active) + expect(shared_runner.tag_list).to include('ruby2.1', 'pgsql', 'mysql') + end + end + + context 'when runner is not shared' do + it 'should update runner' do + description = specific_runner.description + put api("/runners/#{specific_runner.id}", admin), description: 'test' + specific_runner.reload + + expect(response.status).to eq(200) + expect(specific_runner.description).to eq('test') + expect(specific_runner.description).not_to eq(description) + end + end + + it 'should return 404 if runner does not exists' do + put api('/runners/9999', admin), description: 'test' + + expect(response.status).to eq(404) + end + end + + context 'authorized user' do + context 'when runner is shared' do + it 'should not update runner' do + put api("/runners/#{shared_runner.id}", user) + + expect(response.status).to eq(403) + end + end + + context 'when runner is not shared' do + it 'should not update runner without access to it' do + put api("/runners/#{specific_runner.id}", user2) + + expect(response.status).to eq(403) + end + + it 'should update runner with access to it' do + description = specific_runner.description + put api("/runners/#{specific_runner.id}", admin), description: 'test' + specific_runner.reload + + expect(response.status).to eq(200) + expect(specific_runner.description).to eq('test') + expect(specific_runner.description).not_to eq(description) + end + end + end + + context 'unauthorized user' do + it 'should not delete runner' do + put api("/runners/#{specific_runner.id}") + + expect(response.status).to eq(401) + end + end + end + + describe 'DELETE /runners/:id' do + context 'admin user' do + context 'when runner is shared' do + it 'should delete runner' do + expect do + delete api("/runners/#{shared_runner.id}", admin) + end.to change{ Ci::Runner.shared.count }.by(-1) + expect(response.status).to eq(200) + end + end + + context 'when runner is not shared' do + it 'should delete unused runner' do + expect do + delete api("/runners/#{unused_specific_runner.id}", admin) + end.to change{ Ci::Runner.specific.count }.by(-1) + expect(response.status).to eq(200) + end + + it 'should delete used runner' do + expect do + delete api("/runners/#{specific_runner.id}", admin) + end.to change{ Ci::Runner.specific.count }.by(-1) + expect(response.status).to eq(200) + end + end + + it 'should return 404 if runner does not exists' do + delete api('/runners/9999', admin) + + expect(response.status).to eq(404) + end + end + + context 'authorized user' do + context 'when runner is shared' do + it 'should not delete runner' do + delete api("/runners/#{shared_runner.id}", user) + expect(response.status).to eq(403) + end + end + + context 'when runner is not shared' do + it 'should not delete runner without access to it' do + delete api("/runners/#{specific_runner.id}", user2) + expect(response.status).to eq(403) + end + + it 'should not delete runner with more than one associated project' do + delete api("/runners/#{two_projects_runner.id}", user) + expect(response.status).to eq(403) + end + + it 'should delete runner for one owned project' do + expect do + delete api("/runners/#{specific_runner.id}", user) + end.to change{ Ci::Runner.specific.count }.by(-1) + expect(response.status).to eq(200) + end + end + end + + context 'unauthorized user' do + it 'should not delete runner' do + delete api("/runners/#{specific_runner.id}") + + expect(response.status).to eq(401) + end + end + end + + describe 'GET /projects/:id/runners' do + context 'authorized user with master privileges' do + it "should return project's runners" do + get api("/projects/#{project.id}/runners", user) + shared = json_response.any?{ |r| r['is_shared'] } + + expect(response.status).to eq(200) + expect(json_response).to be_an Array + expect(shared).to be_truthy + end + end + + context 'authorized user without master privileges' do + it "should not return project's runners" do + get api("/projects/#{project.id}/runners", user2) + + expect(response.status).to eq(403) + end + end + + context 'unauthorized user' do + it "should not return project's runners" do + get api("/projects/#{project.id}/runners") + + expect(response.status).to eq(401) + end + end + end + + describe 'POST /projects/:id/runners' do + context 'authorized user' do + it 'should enable specific runner' do + specific_runner2 = create(:ci_runner).tap do |runner| + create(:ci_runner_project, runner: runner, project: project2) + end + + expect do + post api("/projects/#{project.id}/runners", user), runner_id: specific_runner2.id + end.to change{ project.runners.count }.by(+1) + expect(response.status).to eq(201) + end + + it 'should avoid changes when enabling already enabled runner' do + expect do + post api("/projects/#{project.id}/runners", user), runner_id: specific_runner.id + end.to change{ project.runners.count }.by(0) + expect(response.status).to eq(201) + end + + it 'should not enable shared runner' do + post api("/projects/#{project.id}/runners", user), runner_id: shared_runner.id + + expect(response.status).to eq(403) + end + + context 'user is admin' do + it 'should enable any specific runner' do + expect do + post api("/projects/#{project.id}/runners", admin), runner_id: unused_specific_runner.id + end.to change{ project.runners.count }.by(+1) + expect(response.status).to eq(201) + end + end + + context 'user is not admin' do + it 'should not enable runner without access to' do + post api("/projects/#{project.id}/runners", user), runner_id: unused_specific_runner.id + + expect(response.status).to eq(403) + end + end + + it 'should raise an error when no runner_id param is provided' do + post api("/projects/#{project.id}/runners", admin) + + expect(response.status).to eq(400) + end + end + + context 'authorized user without permissions' do + it 'should not enable runner' do + post api("/projects/#{project.id}/runners", user2) + + expect(response.status).to eq(403) + end + end + + context 'unauthorized user' do + it 'should not enable runner' do + post api("/projects/#{project.id}/runners") + + expect(response.status).to eq(401) + end + end + end + + describe 'DELETE /projects/:id/runners/:runner_id' do + context 'authorized user' do + context 'when runner have more than one associated projects' do + it "should disable project's runner" do + expect do + delete api("/projects/#{project.id}/runners/#{two_projects_runner.id}", user) + end.to change{ project.runners.count }.by(-1) + expect(response.status).to eq(200) + end + end + + context 'when runner have one associated projects' do + it "should not disable project's runner" do + expect do + delete api("/projects/#{project.id}/runners/#{specific_runner.id}", user) + end.to change{ project.runners.count }.by(0) + expect(response.status).to eq(403) + end + end + + it 'should return 404 is runner is not found' do + delete api("/projects/#{project.id}/runners/9999", user) + + expect(response.status).to eq(404) + end + end + + context 'authorized user without permissions' do + it "should not disable project's runner" do + delete api("/projects/#{project.id}/runners/#{specific_runner.id}", user2) + + expect(response.status).to eq(403) + end + end + + context 'unauthorized user' do + it "should not disable project's runner" do + delete api("/projects/#{project.id}/runners/#{specific_runner.id}") + + expect(response.status).to eq(401) + end + end + end +end |