Commit 99785ff1 authored by Emily Ring's avatar Emily Ring Committed by charlie ablett

Cluster Agent create mutation for GraphQl

Add GraphQl Clusters::Agent create mutation
Add GraphQl Clusters::Agent type
Add Clusters::Agents::CreateService
Added associated tests
Updated associated GraphQl documents
parent ab731834
......@@ -1628,6 +1628,33 @@ type CiStageEdge {
node: CiStage
}
type ClusterAgent {
"""
Timestamp the cluster agent was created
"""
createdAt: Time
"""
ID of the cluster agent
"""
id: ID!
"""
Name of the cluster agent
"""
name: String
"""
The project this cluster agent is associated with
"""
project: Project
"""
Timestamp the cluster agent was updated
"""
updatedAt: Time
}
type Commit {
"""
Author of the commit
......@@ -2260,6 +2287,46 @@ type CreateBranchPayload {
errors: [String!]!
}
"""
Autogenerated input type of CreateClusterAgent
"""
input CreateClusterAgentInput {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Name of the cluster agent
"""
name: String!
"""
Full path of the associated project for this cluster agent
"""
projectPath: ID!
}
"""
Autogenerated return type of CreateClusterAgent
"""
type CreateClusterAgentPayload {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Cluster agent created after mutation
"""
clusterAgent: ClusterAgent
"""
Errors encountered during execution of the mutation.
"""
errors: [String!]!
}
"""
Autogenerated input type of CreateDiffNote
"""
......@@ -9519,6 +9586,7 @@ type Mutation {
createAlertIssue(input: CreateAlertIssueInput!): CreateAlertIssuePayload
createAnnotation(input: CreateAnnotationInput!): CreateAnnotationPayload
createBranch(input: CreateBranchInput!): CreateBranchPayload
createClusterAgent(input: CreateClusterAgentInput!): CreateClusterAgentPayload
createDiffNote(input: CreateDiffNoteInput!): CreateDiffNotePayload
createEpic(input: CreateEpicInput!): CreateEpicPayload
createImageDiffNote(input: CreateImageDiffNoteInput!): CreateImageDiffNotePayload
......
......@@ -4431,6 +4431,93 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "ClusterAgent",
"description": null,
"fields": [
{
"name": "createdAt",
"description": "Timestamp the cluster agent was created",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "id",
"description": "ID of the cluster agent",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "name",
"description": "Name of the cluster agent",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "project",
"description": "The project this cluster agent is associated with",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "Project",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "updatedAt",
"description": "Timestamp the cluster agent was updated",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "Commit",
......@@ -6058,6 +6145,122 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "CreateClusterAgentInput",
"description": "Autogenerated input type of CreateClusterAgent",
"fields": null,
"inputFields": [
{
"name": "projectPath",
"description": "Full path of the associated project for this cluster agent",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "name",
"description": "Name of the cluster agent",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "CreateClusterAgentPayload",
"description": "Autogenerated return type of CreateClusterAgent",
"fields": [
{
"name": "clientMutationId",
"description": "A unique identifier for the client performing the mutation.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "clusterAgent",
"description": "Cluster agent created after mutation",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "ClusterAgent",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "errors",
"description": "Errors encountered during execution of the mutation.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "CreateDiffNoteInput",
......@@ -26993,6 +27196,33 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "createClusterAgent",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "CreateClusterAgentInput",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "CreateClusterAgentPayload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "createDiffNote",
"description": null,
......@@ -264,6 +264,16 @@ Autogenerated return type of BoardListUpdateLimitMetrics
| --- | ---- | ---------- |
| `name` | String | Name of the stage |
## ClusterAgent
| Name | Type | Description |
| --- | ---- | ---------- |
| `createdAt` | Time | Timestamp the cluster agent was created |
| `id` | ID! | ID of the cluster agent |
| `name` | String | Name of the cluster agent |
| `project` | Project | The project this cluster agent is associated with |
| `updatedAt` | Time | Timestamp the cluster agent was updated |
## Commit
| Name | Type | Description |
......@@ -360,6 +370,16 @@ Autogenerated return type of CreateBranch
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
## CreateClusterAgentPayload
Autogenerated return type of CreateClusterAgent
| Name | Type | Description |
| --- | ---- | ---------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `clusterAgent` | ClusterAgent | Cluster agent created after mutation |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
## CreateDiffNotePayload
Autogenerated return type of CreateDiffNote
......
......@@ -6,6 +6,7 @@ module EE
extend ActiveSupport::Concern
prepended do
mount_mutation ::Mutations::Clusters::Agents::Create
mount_mutation ::Mutations::Issues::SetIteration
mount_mutation ::Mutations::Issues::SetWeight
mount_mutation ::Mutations::Issues::SetEpic
......
# frozen_string_literal: true
module Mutations
module Clusters
module Agents
class Create < BaseMutation
include ResolvesProject
authorize :create_cluster
graphql_name 'CreateClusterAgent'
argument :project_path, GraphQL::ID_TYPE,
required: true,
description: 'Full path of the associated project for this cluster agent'
argument :name, GraphQL::STRING_TYPE,
required: true,
description: 'Name of the cluster agent'
field :cluster_agent,
Types::Clusters::AgentType,
null: true,
description: 'Cluster agent created after mutation'
def resolve(project_path:, name:)
project = authorized_find!(full_path: project_path)
result = ::Clusters::Agents::CreateService.new(project, current_user).execute(name: name)
{
cluster_agent: result[:cluster_agent],
errors: Array.wrap(result[:message])
}
end
private
def find_object(full_path:)
resolve_project(full_path: full_path)
end
end
end
end
end
# frozen_string_literal: true
module Types
module Clusters
class AgentType < BaseObject
graphql_name 'ClusterAgent'
authorize :admin_cluster
field :created_at,
Types::TimeType,
null: true,
description: 'Timestamp the cluster agent was created'
field :id, GraphQL::ID_TYPE,
null: false,
description: 'ID of the cluster agent'
field :name,
GraphQL::STRING_TYPE,
null: true,
description: 'Name of the cluster agent'
field :project, Types::ProjectType,
description: 'The project this cluster agent is associated with',
null: true,
authorize: :read_project,
resolve: -> (agent, args, context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, agent.project_id).find }
field :updated_at,
Types::TimeType,
null: true,
description: 'Timestamp the cluster agent was updated'
end
end
end
......@@ -53,6 +53,7 @@ class License < ApplicationRecord
board_milestone_lists
ci_cd_projects
ci_secrets_management
cluster_agents
cluster_deployments
code_owner_approval_required
commit_committer_check
......
# frozen_string_literal: true
module Clusters
class AgentPolicy < BasePolicy
alias_method :cluster_agent, :subject
delegate { cluster_agent.project }
end
end
# frozen_string_literal: true
module Clusters
module Agents
class CreateService < BaseService
def execute(name:)
return error_not_premium_plan unless project.feature_available?(:cluster_agents)
return error_no_permissions unless cluster_agent_permissions?
agent = ::Clusters::Agent.new(name: name, project: project)
if agent.save
success.merge(cluster_agent: agent)
else
error(agent.errors.full_messages)
end
end
private
def cluster_agent_permissions?
current_user.can?(:admin_pipeline, project) && current_user.can?(:create_cluster, project)
end
def error_no_permissions
error(s_('ClusterAgent|You have insufficient permissions to create a cluster agent for this project'))
end
def error_not_premium_plan
error(s_('ClusterAgent|This feature is only available for premium plans'))
end
end
end
end
---
title: Added cluster agent GraphQL mutation and create service
merge_request: 37997
author:
type: added
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::Clusters::Agents::Create do
subject(:mutation) { described_class.new(object: nil, context: context, field: nil) }
let(:project) { create(:project, :public, :repository) }
let(:user) { create(:user) }
let(:context) do
GraphQL::Query::Context.new(
query: OpenStruct.new(schema: nil),
values: { current_user: user },
object: nil
)
end
specify { expect(described_class).to require_graphql_authorizations(:create_cluster) }
describe '#resolve' do
subject { mutation.resolve(project_path: project.full_path, name: 'test-agent') }
context 'without project permissions' do
it 'raises an error if the resource is not accessible to the user' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'without premium plan' do
before do
allow(License).to receive(:current).and_return(create(:license, plan: ::License::STARTER_PLAN))
project.add_maintainer(user)
end
it { expect(subject[:clusters_agent]).to be_nil }
it { expect(subject[:errors]).to eq(['This feature is only available for premium plans']) }
end
context 'with premium plan and user permissions' do
before do
allow(License).to receive(:current).and_return(create(:license, plan: ::License::PREMIUM_PLAN))
project.add_maintainer(user)
end
it 'creates a new clusters_agent', :aggregate_failures do
expect { subject }.to change { ::Clusters::Agent.count }.by(1)
expect(subject[:cluster_agent].name).to eq('test-agent')
expect(subject[:errors]).to eq([])
end
context 'invalid params' do
subject { mutation.resolve(project_path: project.full_path, name: '@bad_name!') }
it 'generates an error message when name is invalid', :aggregate_failures do
expect(subject[:clusters_agent]).to be_nil
expect(subject[:errors]).to eq(["Name can contain only lowercase letters, digits, and '-', but cannot start or end with '-'"])
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['ClusterAgent'] do
let(:fields) { %i[created_at id name project updated_at] }
it { expect(described_class.graphql_name).to eq('ClusterAgent') }
it { expect(described_class).to require_graphql_authorizations(:admin_cluster) }
it { expect(described_class).to have_graphql_fields(fields) }
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Clusters::AgentPolicy do
let(:cluster_agent) { create(:cluster_agent, name: 'agent' )}
let(:user) { create(:admin) }
let(:policy) { described_class.new(user, cluster_agent) }
let(:project) { cluster_agent.project }
describe 'rules' do
context 'when developer' do
before do
project.add_developer(user)
end
it { expect(policy).to be_disallowed :admin_cluster }
end
context 'when maintainer' do
before do
project.add_maintainer(user)
end
it { expect(policy).to be_allowed :admin_cluster }
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Create a new cluster agent' do
include GraphqlHelpers
let(:project) { create(:project, :public, :repository) }
let(:project_name) { 'agent-test' }
let(:current_user) { create(:user) }
let(:mutation) do
graphql_mutation(
:create_cluster_agent,
{ project_path: project.full_path, name: project_name }
)
end
def mutation_response
graphql_mutation_response(:create_cluster_agent)
end
context 'without project permissions' do
it_behaves_like 'a mutation that returns top-level errors',
errors: ['The resource that you are attempting to access does not exist '\
'or you don\'t have permission to perform this action']
it 'does not create cluster agent' do
expect { post_graphql_mutation(mutation, current_user: current_user) }.not_to change(Clusters::Agent, :count)
end
end
context 'without premium plan' do
before do
allow(License).to receive(:current).and_return(create(:license, plan: ::License::STARTER_PLAN))
project.add_maintainer(current_user)
end
it 'does not create cluster agent and returns error message', :aggregate_failures do
expect { post_graphql_mutation(mutation, current_user: current_user) }.not_to change(Clusters::Agent, :count)
expect(mutation_response['errors']).to eq(['This feature is only available for premium plans'])
end
end
context 'with premium plan and user permissions' do
before do
allow(License).to receive(:current).and_return(create(:license, plan: ::License::PREMIUM_PLAN))
project.add_maintainer(current_user)
end
it 'creates a new cluster agent', :aggregate_failures do
expect { post_graphql_mutation(mutation, current_user: current_user) }.to change { Clusters::Agent.count }.by(1)
expect(mutation_response.dig('clusterAgent', 'name')).to eq(project_name)
expect(mutation_response['errors']).to eq([])
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Clusters::Agents::CreateService do
subject(:service) { described_class.new(project, user) }
let(:project) { create(:project, :public, :repository) }
let(:user) { create(:user) }
let(:license) { create(:license, plan: ::License::PREMIUM_PLAN) }
describe '#execute' do
context 'without premium plan' do
before do
allow(License).to receive(:current).and_return(create(:license, plan: ::License::STARTER_PLAN))
end
it 'returns missing plan error' do
expect(service.execute(name: 'without-license')).to eq({
status: :error,
message: 'This feature is only available for premium plans'
})
end
end
context 'without user permissions' do
before do
allow(License).to receive(:current).and_return(license)
end
it 'returns errors when user does not have permissions' do
expect(service.execute(name: 'missing-permissions')).to eq({
status: :error,
message: 'You have insufficient permissions to create a cluster agent for this project'
})
end
end
context 'with premium plan and user permissions' do
before do
allow(License).to receive(:current).and_return(license)
project.add_maintainer(user)
end
it 'creates a new clusters_agent' do
expect { service.execute(name: 'with-license-and-user') }.to change { ::Clusters::Agent.count }.by(1)
end
it 'returns success status', :aggregate_failures do
result = service.execute(name: 'success')
expect(result[:status]).to eq(:success)
expect(result[:message]).to be_nil
end
it 'generates an error message when name is invalid' do
expect(service.execute(name: '@bad_agent_name!')).to eq({
status: :error,
message: ["Name can contain only lowercase letters, digits, and '-', but cannot start or end with '-'"]
})
end
end
end
end
......@@ -5105,6 +5105,12 @@ msgstr ""
msgid "Cluster type must be specificed for Stages::ClusterEndpointInserter"
msgstr ""
msgid "ClusterAgent|This feature is only available for premium plans"
msgstr ""
msgid "ClusterAgent|You have insufficient permissions to create a cluster agent for this project"
msgstr ""
msgid "ClusterIntegration| This will permanently delete the following resources: <ul> <li>All installed applications and related resources</li> <li>The <code>gitlab-managed-apps</code> namespace</li> <li>Any project namespaces</li> <li><code>clusterroles</code></li> <li><code>clusterrolebindings</code></li> </ul>"
msgstr ""
......
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