Commit 560a0baf authored by Yannis Roussos's avatar Yannis Roussos

Merge branch 'add-tokens-to-agents-graphql-endpoint' into 'master'

Add tokens to agents graphql endpoint

See merge request gitlab-org/gitlab!40779
parents 8f871972 0b47d540
......@@ -8,6 +8,8 @@ module Clusters
has_many :agent_tokens, class_name: 'Clusters::AgentToken'
scope :with_name, -> (name) { where(name: name) }
validates :name,
presence: true,
length: { maximum: 63 },
......
......@@ -1709,6 +1709,31 @@ type ClusterAgent {
"""
project: Project
"""
Tokens associated with the cluster agent
"""
tokens(
"""
Returns the elements in the list that come after the specified cursor.
"""
after: String
"""
Returns the elements in the list that come before the specified cursor.
"""
before: String
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
Returns the last _n_ elements from the list.
"""
last: Int
): ClusterAgentTokenConnection
"""
Timestamp the cluster agent was updated
"""
......@@ -1797,6 +1822,26 @@ type ClusterAgentToken {
id: ClustersAgentTokenID!
}
"""
The connection type for ClusterAgentToken.
"""
type ClusterAgentTokenConnection {
"""
A list of edges.
"""
edges: [ClusterAgentTokenEdge]
"""
A list of nodes.
"""
nodes: [ClusterAgentToken]
"""
Information to aid in pagination.
"""
pageInfo: PageInfo!
}
"""
Autogenerated input type of ClusterAgentTokenCreate
"""
......@@ -1867,6 +1912,21 @@ type ClusterAgentTokenDeletePayload {
errors: [String!]!
}
"""
An edge in a connection.
"""
type ClusterAgentTokenEdge {
"""
A cursor for use in pagination.
"""
cursor: String!
"""
The item at the end of the edge.
"""
node: ClusterAgentToken
}
"""
Identifier of Clusters::Agent
"""
......@@ -11776,6 +11836,16 @@ type Project {
last: Int
): BoardConnection
"""
Find a single cluster agent by name
"""
clusterAgent(
"""
Name of the cluster agent
"""
name: String!
): ClusterAgent
"""
Cluster agents associated with the project
"""
......
......@@ -4659,6 +4659,59 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "tokens",
"description": "Tokens associated with the cluster agent",
"args": [
{
"name": "after",
"description": "Returns the elements in the list that come after the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "before",
"description": "Returns the elements in the list that come before the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "first",
"description": "Returns the first _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
},
{
"name": "last",
"description": "Returns the last _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "ClusterAgentTokenConnection",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "updatedAt",
"description": "Timestamp the cluster agent was updated",
......@@ -4940,6 +4993,73 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "ClusterAgentTokenConnection",
"description": "The connection type for ClusterAgentToken.",
"fields": [
{
"name": "edges",
"description": "A list of edges.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "ClusterAgentTokenEdge",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "nodes",
"description": "A list of nodes.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "ClusterAgentToken",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "pageInfo",
"description": "Information to aid in pagination.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "PageInfo",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "ClusterAgentTokenCreateInput",
......@@ -5144,6 +5264,51 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "ClusterAgentTokenEdge",
"description": "An edge in a connection.",
"fields": [
{
"name": "cursor",
"description": "A cursor for use in pagination.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "node",
"description": "The item at the end of the edge.",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "ClusterAgentToken",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "SCALAR",
"name": "ClustersAgentID",
......@@ -35050,6 +35215,33 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "clusterAgent",
"description": "Find a single cluster agent by name",
"args": [
{
"name": "name",
"description": "Name of the cluster agent",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "ClusterAgent",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "clusterAgents",
"description": "Cluster agents associated with the project",
......@@ -1771,6 +1771,7 @@ Autogenerated return type of PipelineRetry
| `autocloseReferencedIssues` | Boolean | Indicates if issues referenced by merge requests and commits within the default branch are closed automatically |
| `avatarUrl` | String | URL to avatar image file of the project |
| `board` | Board | A single board of the project |
| `clusterAgent` | ClusterAgent | Find a single cluster agent by name |
| `containerExpirationPolicy` | ContainerExpirationPolicy | The container expiration policy of the project |
| `containerRegistryEnabled` | Boolean | Indicates if the project stores Docker container images in a container registry |
| `createdAt` | Time | Timestamp of the project creation |
......
......@@ -2,20 +2,24 @@
module Clusters
class AgentsFinder
def initialize(project, current_user)
def initialize(project, current_user, params: {})
@project = project
@current_user = current_user
@params = params
end
def execute
return ::Clusters::Agent.none unless can_read_cluster_agents?
project.cluster_agents
agents = project.cluster_agents
agents = agents.with_name(params[:name]) if params[:name].present?
agents
end
private
attr_reader :project, :current_user
attr_reader :project, :current_user, :params
def can_read_cluster_agents?
project.feature_available?(:cluster_agents) && current_user.can?(:read_cluster, project)
......
......@@ -99,8 +99,15 @@ module EE
description: 'DAST Site Profiles associated with the project',
resolve: -> (obj, _args, _ctx) { obj.dast_site_profiles.with_dast_site }
field :cluster_agent,
::Types::Clusters::AgentType,
null: true,
description: 'Find a single cluster agent by name',
resolver: ::Resolvers::Clusters::AgentResolver.single
field :cluster_agents,
::Types::Clusters::AgentType.connection_type,
extras: [:lookahead],
null: true,
description: 'Cluster agents associated with the project',
resolver: ::Resolvers::Clusters::AgentsResolver
......
# frozen_string_literal: true
module Resolvers
module Clusters
class AgentResolver < AgentsResolver
argument :name, GraphQL::STRING_TYPE,
required: true,
description: 'Name of the cluster agent'
end
end
end
# frozen_string_literal: true
module Resolvers
module Clusters
class AgentTokensResolver < BaseResolver
type Types::Clusters::AgentTokenType, null: true
alias_method :agent, :object
delegate :project, to: :agent
def resolve(**args)
return ::Clusters::AgentToken.none unless can_read_agent_tokens?
agent.agent_tokens
end
private
def can_read_agent_tokens?
project.feature_available?(:cluster_agents) && current_user.can?(:admin_cluster, project)
end
end
end
end
......@@ -3,14 +3,24 @@
module Resolvers
module Clusters
class AgentsResolver < BaseResolver
include LooksAhead
type Types::Clusters::AgentType, null: true
alias_method :project, :object
def resolve(**args)
::Clusters::AgentsFinder
.new(project, context[:current_user])
.execute
def resolve_with_lookahead(**args)
apply_lookahead(
::Clusters::AgentsFinder
.new(project, context[:current_user], params: args)
.execute
)
end
private
def preloads
{ tokens: :agent_tokens }
end
end
end
......
......@@ -27,6 +27,11 @@ module Types
authorize: :read_project,
resolve: -> (agent, args, context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, agent.project_id).find }
field :tokens, Types::Clusters::AgentTokenType.connection_type,
description: 'Tokens associated with the cluster agent',
null: true,
resolver: ::Resolvers::Clusters::AgentTokensResolver
field :updated_at,
Types::TimeType,
null: true,
......
---
title: Allow fetching agent tokens from cluster agent GraphQL endpoint
merge_request: 40779
author:
type: added
......@@ -30,5 +30,23 @@ RSpec.describe Clusters::AgentsFinder do
it { is_expected.to be_empty }
end
context 'filtering by name' do
let(:params) { Hash(name: name_param) }
subject { described_class.new(project, user, params: params).execute }
context 'name does not match' do
let(:name_param) { 'other-name' }
it { is_expected.to be_empty }
end
context 'name does match' do
let(:name_param) { matching_agent.name }
it { is_expected.to contain_exactly(matching_agent) }
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Resolvers::Clusters::AgentResolver do
it { expect(described_class).to be < Resolvers::Clusters::AgentsResolver }
describe 'arguments' do
subject { described_class.arguments[argument] }
describe 'name' do
let(:argument) { 'name' }
it do
expect(subject).to be_present
expect(subject.type.to_s).to eq('String!')
expect(subject.description).to be_present
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Resolvers::Clusters::AgentTokensResolver do
include GraphqlHelpers
it { expect(described_class.type).to eq(Types::Clusters::AgentTokenType) }
it { expect(described_class.null).to be_truthy }
describe '#resolve' do
let(:agent) { create(:cluster_agent) }
let(:user) { create(:user, maintainer_projects: [agent.project]) }
let(:feature_available) { true }
let(:ctx) { Hash(current_user: user) }
let!(:matching_token1) { create(:cluster_agent_token, agent: agent) }
let!(:mathcing_token2) { create(:cluster_agent_token, agent: agent) }
let!(:other_token) { create(:cluster_agent_token) }
subject { resolve(described_class, obj: agent, ctx: ctx) }
before do
stub_licensed_features(cluster_agents: feature_available)
end
it 'returns tokens associated with the agent' do
expect(subject).to contain_exactly(matching_token1, mathcing_token2)
end
context 'feature is not available' do
let(:feature_available) { false }
it { is_expected.to be_empty }
end
context 'user does not have permission' do
let(:user) { create(:user, developer_projects: [agent.project]) }
it { is_expected.to be_empty }
end
end
end
......@@ -5,22 +5,36 @@ require 'spec_helper'
RSpec.describe Resolvers::Clusters::AgentsResolver do
include GraphqlHelpers
it { expect(described_class).to be < LooksAhead }
it { expect(described_class.type).to eq(Types::Clusters::AgentType) }
it { expect(described_class.null).to be_truthy }
describe '#resolve' do
let_it_be(:user) { create(:user) }
let(:finder) { double(execute: :result) }
let(:finder) { double(execute: relation) }
let(:relation) { double }
let(:project) { create(:project) }
let(:args) { Hash(key: 'value') }
let(:ctx) { Hash(current_user: user) }
let(:lookahead) do
double(selects?: true).tap do |selection|
allow(selection).to receive(:selection).and_return(selection)
end
end
subject { resolve(described_class, obj: project, ctx: { current_user: user }) }
subject { resolve(described_class, obj: project, args: args.merge(lookahead: lookahead), ctx: ctx) }
it 'calls the agents finder' do
expect(::Clusters::AgentsFinder).to receive(:new)
.with(project, user).and_return(finder)
.with(project, user, params: args).and_return(finder)
expect(relation).to receive(:preload)
.with(:agent_tokens).and_return(relation)
expect(subject).to eq(:result)
expect(subject).to eq(relation)
end
end
end
......@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe GitlabSchema.types['ClusterAgent'] do
let(:fields) { %i[created_at id name project updated_at] }
let(:fields) { %i[created_at id name project updated_at tokens] }
it { expect(described_class.graphql_name).to eq('ClusterAgent') }
......
......@@ -228,4 +228,44 @@ RSpec.describe GitlabSchema.types['Project'] do
expect(agents.first['project']['id']).to eq(project.to_global_id.to_s)
end
end
describe 'cluster_agent' do
let_it_be(:cluster_agent) { create(:cluster_agent, project: project, name: 'agent-name') }
let_it_be(:agent_token) { create(:cluster_agent_token, agent: cluster_agent) }
let_it_be(:query) do
%(
query {
project(fullPath: "#{project.full_path}") {
clusterAgent(name: "#{cluster_agent.name}") {
id
tokens {
nodes {
id
}
}
}
}
}
)
end
subject { GitlabSchema.execute(query, context: { current_user: user }).as_json }
before do
stub_licensed_features(cluster_agents: true)
project.add_maintainer(user)
end
it 'returns associated cluster agents' do
agent = subject.dig('data', 'project', 'clusterAgent')
tokens = agent.dig('tokens', 'nodes')
expect(agent['id']).to eq(cluster_agent.to_global_id.to_s)
expect(tokens.count).to be(1)
expect(tokens.first['id']).to eq(agent_token.to_global_id.to_s)
end
end
end
......@@ -12,6 +12,17 @@ RSpec.describe Clusters::Agent do
it { is_expected.to validate_length_of(:name).is_at_most(63) }
it { is_expected.to validate_uniqueness_of(:name).scoped_to(:project_id) }
describe 'scopes' do
describe '.with_name' do
let!(:matching_name) { create(:cluster_agent, name: 'matching-name') }
let!(:other_name) { create(:cluster_agent, name: 'other-name') }
subject { described_class.with_name(matching_name.name) }
it { is_expected.to contain_exactly(matching_name) }
end
end
describe 'validation' do
describe 'name validation' do
it 'rejects names that do not conform to RFC 1123', :aggregate_failures do
......
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