Commit 12063651 authored by Emily Ring's avatar Emily Ring Committed by Mark Chao

New /job/allowed_agents REST endpoint

parent 760d6c5f
......@@ -459,6 +459,86 @@ Example of response
}
```
## Get Kubernetes Agents by `CI_JOB_TOKEN` **(PREMIUM)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/324269) in [GitLab Premium](https://about.gitlab.com/pricing/) 13.11.
Retrieve the job that generated the `CI_JOB_TOKEN`, along with a list of allowed GitLab
Kubernetes Agents.
```plaintext
GET /job/allowed_agents
```
Supported attributes:
| Attribute | Type | Required | Description |
|:------------ |:---------|:---------|:----------------------|
| `CI_JOB_TOKEN` | string | yes | Token value associated with the GitLab-provided `CI_JOB_TOKEN` variable. |
Example request:
```shell
curl --header "JOB-TOKEN: <CI_JOB_TOKEN>" "https://gitlab.example.com/api/v4/job/allowed_agents"
curl "https://gitlab.example.com/api/v4/job/allowed_agents?job_token=<CI_JOB_TOKEN>"
```
Example response:
```json
{
"allowed_agents":
[
{
"id": 1,
"config_project": {
"id": 1,
"description": null,
"name": "project1",
"name_with_namespace": "John Doe2 / project1",
"path": "project1",
"path_with_namespace": "namespace1/project1",
"created_at": "2021-03-26T14:51:50.579Z"
}
}
],
"job": {
"id": 1,
"name": "test",
"stage": "test",
"project_id": 1,
"project_name": "project1"
},
"pipeline": {
"id": 1,
"project_id": 1,
"sha": "b83d6e391c22777fca1ed3012fce84f633d7fed0",
"ref": "master",
"status": "pending",
"created_at": "2021-03-26T14:51:51.107Z",
"updated_at": "2021-03-26T14:51:51.107Z",
"web_url": "http://localhost/namespace1/project1/-/pipelines/1"
},
"project": {
"id": 1,
"description": null,
"name": "project1",
"name_with_namespace": "John Doe2 / project1",
"path": "project1",
"path_with_namespace": "namespace1/project1",
"created_at": "2021-03-26T14:51:50.579Z"
},
"user": {
"id": 2,
"name": "John Doe3",
"username": "user2",
"state": "active",
"avatar_url": "https://www.gravatar.com/avatar/10fc7f102b",
"web_url": "http://localhost/user2"
}
}
```
## Get a single job
Get a single job of a project
......
# frozen_string_literal: true
module Clusters
class DeployableAgentsFinder
def initialize(project)
@project = project
end
def execute
return ::Clusters::Agent.none unless allowed?
project.cluster_agents.ordered_by_name
end
private
attr_reader :project
def allowed?
project.licensed_feature_available?(:cluster_agents)
end
end
end
---
title: New allowed_agents REST endpoint
merge_request: 56700
author:
type: added
# frozen_string_literal: true
module EE
module API
module Jobs
extend ActiveSupport::Concern
prepended do
resource :job do
desc 'Get current agents' do
detail 'Retrieves a list of agents for the given job token'
end
route_setting :authentication, job_token_allowed: true
get '/allowed_agents', feature_category: :kubernetes_management do
validate_current_authenticated_job
status 200
pipeline = current_authenticated_job.pipeline
project = current_authenticated_job.project
allowed_agents = ::Clusters::DeployableAgentsFinder.new(project).execute
{
allowed_agents: ::API::Entities::Clusters::Agent.represent(allowed_agents),
job: ::API::Entities::JobRequest::JobInfo.represent(current_authenticated_job),
pipeline: ::API::Entities::Ci::PipelineBasic.represent(pipeline),
project: ::API::Entities::ProjectIdentity.represent(project),
user: ::API::Entities::UserBasic.represent(current_user)
}
end
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Clusters::DeployableAgentsFinder do
describe '#execute' do
let_it_be(:agent) { create(:cluster_agent) }
let(:project) { agent.project }
subject { described_class.new(project).execute }
before do
stub_licensed_features(cluster_agents: feature_available)
end
context 'feature is available' do
let(:feature_available) { true }
it { is_expected.to contain_exactly(agent) }
end
context 'feature is not available' do
let(:feature_available) { false }
it { is_expected.to be_empty }
end
end
end
......@@ -25,6 +25,90 @@ RSpec.describe API::Jobs do
project.add_developer(developer)
end
describe 'GET /job/allowed_agents' do
let_it_be(:agent) { create(:cluster_agent, project: project) }
let(:api_user) { developer }
let(:headers) { { API::Helpers::Runner::JOB_TOKEN_HEADER => job.token } }
let(:job) { create(:ci_build, :artifacts, pipeline: pipeline, user: api_user, status: job_status) }
let(:job_status) { 'running' }
let(:params) { {} }
subject do
get api('/job/allowed_agents'), headers: headers, params: params
end
before do
stub_licensed_features(cluster_agents: true)
agent
subject
end
context 'when token is valid and user is authorized' do
it 'returns agent info', :aggregate_failures do
expect(response).to have_gitlab_http_status(:ok)
expect(json_response.dig('job', 'id')).to eq(job.id)
expect(json_response.dig('pipeline', 'id')).to eq(job.pipeline_id)
expect(json_response.dig('project', 'id')).to eq(job.project_id)
expect(json_response.dig('user', 'username')).to eq(api_user.username)
expect(json_response['allowed_agents']).to match_array([
{ 'id' => agent.id, 'config_project' => a_hash_including('id' => agent.project_id) }
])
end
context 'when passing the token as params' do
let(:headers) { {} }
let(:params) { { job_token: job.token } }
it 'returns agent info', :aggregate_failures do
expect(response).to have_gitlab_http_status(:ok)
expect(json_response.dig('job', 'id')).to eq(job.id)
expect(json_response.dig('pipeline', 'id')).to eq(job.pipeline_id)
expect(json_response.dig('project', 'id')).to eq(job.project_id)
expect(json_response.dig('user', 'username')).to eq(api_user.username)
expect(json_response['allowed_agents']).to match_array([
{ 'id' => agent.id, 'config_project' => a_hash_including('id' => agent.project_id) }
])
end
end
end
context 'when user is anonymous' do
let(:api_user) { nil }
it 'returns unauthorized' do
expect(response).to have_gitlab_http_status(:unauthorized)
end
end
context 'when token is invalid because job has finished' do
let(:job_status) { 'success' }
it 'returns unauthorized' do
expect(response).to have_gitlab_http_status(:unauthorized)
end
end
context 'when token is invalid' do
let(:headers) { { API::Helpers::Runner::JOB_TOKEN_HEADER => 'bad_token' } }
it 'returns unauthorized' do
expect(response).to have_gitlab_http_status(:unauthorized)
end
end
context 'when token is valid but not CI_JOB_TOKEN' do
let(:token) { create(:personal_access_token, user: developer) }
let(:headers) { { 'Private-Token' => token.token } }
it 'returns not found' do
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
describe 'GET /projects/:id/jobs/:job_id/artifacts' do
let(:job) { create(:ci_build, :artifacts, pipeline: pipeline, user: api_user, status: :running) }
......
# frozen_string_literal: true
module API
module Entities
module Clusters
class Agent < Grape::Entity
expose :id
expose :project, with: Entities::ProjectIdentity, as: :config_project
end
end
end
end
......@@ -4,7 +4,7 @@ module API
module Entities
module JobRequest
class JobInfo < Grape::Entity
expose :name, :stage
expose :id, :name, :stage
expose :project_id, :project_name
end
end
......
......@@ -6,8 +6,6 @@ module API
before { authenticate! }
feature_category :continuous_integration
resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
params do
requires :id, type: String, desc: 'The ID of a project'
......@@ -40,7 +38,7 @@ module API
use :pagination
end
# rubocop: disable CodeReuse/ActiveRecord
get ':id/jobs' do
get ':id/jobs', feature_category: :continuous_integration do
authorize_read_builds!
builds = user_project.builds.order('id DESC')
......@@ -57,7 +55,7 @@ module API
params do
requires :job_id, type: Integer, desc: 'The ID of a job'
end
get ':id/jobs/:job_id' do
get ':id/jobs/:job_id', feature_category: :continuous_integration do
authorize_read_builds!
build = find_build!(params[:job_id])
......@@ -72,7 +70,7 @@ module API
params do
requires :job_id, type: Integer, desc: 'The ID of a job'
end
get ':id/jobs/:job_id/trace' do
get ':id/jobs/:job_id/trace', feature_category: :continuous_integration do
authorize_read_builds!
build = find_build!(params[:job_id])
......@@ -94,7 +92,7 @@ module API
params do
requires :job_id, type: Integer, desc: 'The ID of a job'
end
post ':id/jobs/:job_id/cancel' do
post ':id/jobs/:job_id/cancel', feature_category: :continuous_integration do
authorize_update_builds!
build = find_build!(params[:job_id])
......@@ -111,7 +109,7 @@ module API
params do
requires :job_id, type: Integer, desc: 'The ID of a build'
end
post ':id/jobs/:job_id/retry' do
post ':id/jobs/:job_id/retry', feature_category: :continuous_integration do
authorize_update_builds!
build = find_build!(params[:job_id])
......@@ -129,7 +127,7 @@ module API
params do
requires :job_id, type: Integer, desc: 'The ID of a build'
end
post ':id/jobs/:job_id/erase' do
post ':id/jobs/:job_id/erase', feature_category: :continuous_integration do
authorize_update_builds!
build = find_build!(params[:job_id])
......@@ -148,7 +146,7 @@ module API
requires :job_id, type: Integer, desc: 'The ID of a Job'
end
post ":id/jobs/:job_id/play" do
post ":id/jobs/:job_id/play", feature_category: :continuous_integration do
authorize_read_builds!
job = find_job!(params[:job_id])
......@@ -174,10 +172,8 @@ module API
success Entities::Ci::Job
end
route_setting :authentication, job_token_allowed: true
get do
# current_authenticated_job will be nil if user is using
# a valid authentication that is not CI_JOB_TOKEN
not_found!('Job') unless current_authenticated_job
get '', feature_category: :continuous_integration do
validate_current_authenticated_job
present current_authenticated_job, with: Entities::Ci::Job
end
......@@ -196,6 +192,14 @@ module API
builds.where(status: available_statuses && scope)
end
# rubocop: enable CodeReuse/ActiveRecord
def validate_current_authenticated_job
# current_authenticated_job will be nil if user is using
# a valid authentication (like PRIVATE-TOKEN) that is not CI_JOB_TOKEN
not_found!('Job') unless current_authenticated_job
end
end
end
end
API::Jobs.prepend_if_ee('EE::API::Jobs')
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe API::Entities::Clusters::Agent do
let_it_be(:cluster_agent) { create(:cluster_agent) }
subject { described_class.new(cluster_agent).as_json }
it 'includes basic fields' do
expect(subject).to include(
id: cluster_agent.id,
config_project: a_hash_including(id: cluster_agent.project_id)
)
end
end
......@@ -143,7 +143,8 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
context 'when there is a pending job' do
let(:expected_job_info) do
{ 'name' => job.name,
{ 'id' => job.id,
'name' => job.name,
'stage' => job.stage,
'project_id' => job.project.id,
'project_name' => job.project.name }
......
......@@ -100,6 +100,18 @@ RSpec.describe API::Jobs do
end
end
context 'when token is valid but not CI_JOB_TOKEN' do
let(:token) { create(:personal_access_token, user: user) }
include_context 'with auth headers' do
let(:header) { { 'Private-Token' => token.token } }
end
it 'returns not found' do
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'with job token authentication header' do
include_context 'with auth headers' do
let(:header) { { API::Helpers::Runner::JOB_TOKEN_HEADER => running_job.token } }
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment