Commit b70c06d4 authored by Dmytro Zaporozhets (DZ)'s avatar Dmytro Zaporozhets (DZ)

Merge branch '216571-terraform-states-graphql-endpoint' into 'master'

Add GraphQL endpoint for Terraform state metadata

See merge request gitlab-org/gitlab!43375
parents 1afb822f 77e10cae
# frozen_string_literal: true
module Resolvers
module Terraform
class StatesResolver < BaseResolver
type Types::Terraform::StateType, null: true
alias_method :project, :object
def resolve(**args)
return ::Terraform::State.none unless can_read_terraform_states?
project.terraform_states.ordered_by_name
end
private
def can_read_terraform_states?
current_user.can?(:read_terraform_state, project)
end
end
end
end
...@@ -294,6 +294,12 @@ module Types ...@@ -294,6 +294,12 @@ module Types
description: 'Title of the label' description: 'Title of the label'
end end
field :terraform_states,
Types::Terraform::StateType.connection_type,
null: true,
description: 'Terraform states associated with the project',
resolver: Resolvers::Terraform::StatesResolver
def label(title:) def label(title:)
BatchLoader::GraphQL.for(title).batch(key: project) do |titles, loader, args| BatchLoader::GraphQL.for(title).batch(key: project) do |titles, loader, args|
LabelsFinder LabelsFinder
......
# frozen_string_literal: true
module Types
module Terraform
class StateType < BaseObject
graphql_name 'TerraformState'
authorize :read_terraform_state
field :id, GraphQL::ID_TYPE,
null: false,
description: 'ID of the Terraform state'
field :name, GraphQL::STRING_TYPE,
null: false,
description: 'Name of the Terraform state'
field :locked_by_user, Types::UserType,
null: true,
authorize: :read_user,
description: 'The user currently holding a lock on the Terraform state',
resolve: -> (state, _, _) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, state.locked_by_user_id).find }
field :locked_at, Types::TimeType,
null: true,
description: 'Timestamp the Terraform state was locked'
field :created_at, Types::TimeType,
null: false,
description: 'Timestamp the Terraform state was created'
field :updated_at, Types::TimeType,
null: false,
description: 'Timestamp the Terraform state was updated'
end
end
end
...@@ -337,6 +337,8 @@ class Project < ApplicationRecord ...@@ -337,6 +337,8 @@ class Project < ApplicationRecord
has_many :webide_pipelines, -> { webide_source }, class_name: 'Ci::Pipeline', inverse_of: :project has_many :webide_pipelines, -> { webide_source }, class_name: 'Ci::Pipeline', inverse_of: :project
has_many :reviews, inverse_of: :project has_many :reviews, inverse_of: :project
has_many :terraform_states, class_name: 'Terraform::State', inverse_of: :project
# GitLab Pages # GitLab Pages
has_many :pages_domains has_many :pages_domains
has_one :pages_metadatum, class_name: 'ProjectPagesMetadatum', inverse_of: :project has_one :pages_metadatum, class_name: 'ProjectPagesMetadatum', inverse_of: :project
......
...@@ -15,6 +15,7 @@ module Terraform ...@@ -15,6 +15,7 @@ module Terraform
has_one :latest_version, -> { ordered_by_version_desc }, class_name: 'Terraform::StateVersion', foreign_key: :terraform_state_id has_one :latest_version, -> { ordered_by_version_desc }, class_name: 'Terraform::StateVersion', foreign_key: :terraform_state_id
scope :versioning_not_enabled, -> { where(versioning_enabled: false) } scope :versioning_not_enabled, -> { where(versioning_enabled: false) }
scope :ordered_by_name, -> { order(:name) }
validates :project_id, presence: true validates :project_id, presence: true
validates :uuid, presence: true, uniqueness: true, length: { is: UUID_LENGTH }, validates :uuid, presence: true, uniqueness: true, length: { is: UUID_LENGTH },
......
# frozen_string_literal: true
module Terraform
class StatePolicy < BasePolicy
alias_method :terraform_state, :subject
delegate { terraform_state.project }
end
end
---
title: Add GraphQL endpoint for Terraform state metadata
merge_request: 43375
author:
type: added
...@@ -14088,6 +14088,31 @@ type Project { ...@@ -14088,6 +14088,31 @@ type Project {
""" """
tagList: String tagList: String
"""
Terraform states associated with the project
"""
terraformStates(
"""
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
): TerraformStateConnection
""" """
Permissions for the current user on the resource Permissions for the current user on the resource
""" """
...@@ -17632,6 +17657,73 @@ type TaskCompletionStatus { ...@@ -17632,6 +17657,73 @@ type TaskCompletionStatus {
count: Int! count: Int!
} }
type TerraformState {
"""
Timestamp the Terraform state was created
"""
createdAt: Time!
"""
ID of the Terraform state
"""
id: ID!
"""
Timestamp the Terraform state was locked
"""
lockedAt: Time
"""
The user currently holding a lock on the Terraform state
"""
lockedByUser: User
"""
Name of the Terraform state
"""
name: String!
"""
Timestamp the Terraform state was updated
"""
updatedAt: Time!
}
"""
The connection type for TerraformState.
"""
type TerraformStateConnection {
"""
A list of edges.
"""
edges: [TerraformStateEdge]
"""
A list of nodes.
"""
nodes: [TerraformState]
"""
Information to aid in pagination.
"""
pageInfo: PageInfo!
}
"""
An edge in a connection.
"""
type TerraformStateEdge {
"""
A cursor for use in pagination.
"""
cursor: String!
"""
The item at the end of the edge.
"""
node: TerraformState
}
""" """
Represents the Geo sync and verification state of a terraform state Represents the Geo sync and verification state of a terraform state
""" """
......
...@@ -40892,6 +40892,59 @@ ...@@ -40892,6 +40892,59 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
}, },
{
"name": "terraformStates",
"description": "Terraform states associated with the project",
"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": "TerraformStateConnection",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "userPermissions", "name": "userPermissions",
"description": "Permissions for the current user on the resource", "description": "Permissions for the current user on the resource",
...@@ -51365,6 +51418,231 @@ ...@@ -51365,6 +51418,231 @@
"enumValues": null, "enumValues": null,
"possibleTypes": null "possibleTypes": null
}, },
{
"kind": "OBJECT",
"name": "TerraformState",
"description": null,
"fields": [
{
"name": "createdAt",
"description": "Timestamp the Terraform state was created",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "id",
"description": "ID of the Terraform state",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "lockedAt",
"description": "Timestamp the Terraform state was locked",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "lockedByUser",
"description": "The user currently holding a lock on the Terraform state",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "User",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "name",
"description": "Name of the Terraform state",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "updatedAt",
"description": "Timestamp the Terraform state was updated",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "TerraformStateConnection",
"description": "The connection type for TerraformState.",
"fields": [
{
"name": "edges",
"description": "A list of edges.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "TerraformStateEdge",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "nodes",
"description": "A list of nodes.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "TerraformState",
"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": "OBJECT",
"name": "TerraformStateEdge",
"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": "TerraformState",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{ {
"kind": "OBJECT", "kind": "OBJECT",
"name": "TerraformStateRegistry", "name": "TerraformStateRegistry",
...@@ -2473,6 +2473,17 @@ Completion status of tasks. ...@@ -2473,6 +2473,17 @@ Completion status of tasks.
| `completedCount` | Int! | Number of completed tasks | | `completedCount` | Int! | Number of completed tasks |
| `count` | Int! | Number of total tasks | | `count` | Int! | Number of total tasks |
### TerraformState
| Field | Type | Description |
| ----- | ---- | ----------- |
| `createdAt` | Time! | Timestamp the Terraform state was created |
| `id` | ID! | ID of the Terraform state |
| `lockedAt` | Time | Timestamp the Terraform state was locked |
| `lockedByUser` | User | The user currently holding a lock on the Terraform state |
| `name` | String! | Name of the Terraform state |
| `updatedAt` | Time! | Timestamp the Terraform state was updated |
### TerraformStateRegistry ### TerraformStateRegistry
Represents the Geo sync and verification state of a terraform state. Represents the Geo sync and verification state of a terraform state.
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Resolvers::Terraform::StatesResolver do
include GraphqlHelpers
it { expect(described_class.type).to eq(Types::Terraform::StateType) }
it { expect(described_class.null).to be_truthy }
describe '#resolve' do
let_it_be(:project) { create(:project) }
let_it_be(:production_state) { create(:terraform_state, project: project) }
let_it_be(:staging_state) { create(:terraform_state, project: project) }
let_it_be(:other_state) { create(:terraform_state) }
let(:ctx) { Hash(current_user: user) }
let(:user) { create(:user, developer_projects: [project]) }
subject { resolve(described_class, obj: project, ctx: ctx) }
it 'returns states associated with the agent' do
expect(subject).to contain_exactly(production_state, staging_state)
end
context 'user does not have permission' do
let(:user) { create(:user) }
it { is_expected.to be_empty }
end
end
end
...@@ -27,7 +27,7 @@ RSpec.describe GitlabSchema.types['Project'] do ...@@ -27,7 +27,7 @@ RSpec.describe GitlabSchema.types['Project'] do
environment boards jira_import_status jira_imports services releases release environment boards jira_import_status jira_imports services releases release
alert_management_alerts alert_management_alert alert_management_alert_status_counts alert_management_alerts alert_management_alert alert_management_alert_status_counts
container_expiration_policy service_desk_enabled service_desk_address container_expiration_policy service_desk_enabled service_desk_address
issue_status_counts issue_status_counts terraform_states
] ]
expect(described_class).to include_graphql_fields(*expected_fields) expect(described_class).to include_graphql_fields(*expected_fields)
...@@ -154,5 +154,12 @@ RSpec.describe GitlabSchema.types['Project'] do ...@@ -154,5 +154,12 @@ RSpec.describe GitlabSchema.types['Project'] do
it { is_expected.to have_graphql_type(Types::ContainerExpirationPolicyType) } it { is_expected.to have_graphql_type(Types::ContainerExpirationPolicyType) }
end end
describe 'terraform states field' do
subject { described_class.fields['terraformStates'] }
it { is_expected.to have_graphql_type(Types::Terraform::StateType.connection_type) }
it { is_expected.to have_graphql_resolver(Resolvers::Terraform::StatesResolver) }
end
it_behaves_like 'a GraphQL type with labels' it_behaves_like 'a GraphQL type with labels'
end end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['TerraformState'] do
it { expect(described_class.graphql_name).to eq('TerraformState') }
it { expect(described_class).to require_graphql_authorizations(:read_terraform_state) }
describe 'fields' do
let(:fields) { %i[id name locked_by_user locked_at created_at updated_at] }
it { expect(described_class).to have_graphql_fields(fields) }
it { expect(described_class.fields['id'].type).to be_non_null }
it { expect(described_class.fields['name'].type).to be_non_null }
it { expect(described_class.fields['lockedByUser'].type).not_to be_non_null }
it { expect(described_class.fields['lockedAt'].type).not_to be_non_null }
it { expect(described_class.fields['createdAt'].type).to be_non_null }
it { expect(described_class.fields['updatedAt'].type).to be_non_null }
end
end
...@@ -537,6 +537,7 @@ project: ...@@ -537,6 +537,7 @@ project:
- vulnerability_historical_statistics - vulnerability_historical_statistics
- product_analytics_events - product_analytics_events
- pipeline_artifacts - pipeline_artifacts
- terraform_states
award_emoji: award_emoji:
- awardable - awardable
- user - user
......
...@@ -123,6 +123,7 @@ RSpec.describe Project do ...@@ -123,6 +123,7 @@ RSpec.describe Project do
it { is_expected.to have_many(:packages).class_name('Packages::Package') } it { is_expected.to have_many(:packages).class_name('Packages::Package') }
it { is_expected.to have_many(:package_files).class_name('Packages::PackageFile') } it { is_expected.to have_many(:package_files).class_name('Packages::PackageFile') }
it { is_expected.to have_many(:pipeline_artifacts) } it { is_expected.to have_many(:pipeline_artifacts) }
it { is_expected.to have_many(:terraform_states).class_name('Terraform::State').inverse_of(:project) }
# GitLab Pages # GitLab Pages
it { is_expected.to have_many(:pages_domains) } it { is_expected.to have_many(:pages_domains) }
......
...@@ -18,6 +18,23 @@ RSpec.describe Terraform::State do ...@@ -18,6 +18,23 @@ RSpec.describe Terraform::State do
stub_terraform_state_object_storage stub_terraform_state_object_storage
end end
describe 'scopes' do
describe '.ordered_by_name' do
let_it_be(:project) { create(:project) }
let(:names) { %w(state_d state_b state_a state_c) }
subject { described_class.ordered_by_name }
before do
names.each do |name|
create(:terraform_state, project: project, name: name)
end
end
it { expect(subject.map(&:name)).to eq(names.sort) }
end
end
describe '#file' do describe '#file' do
context 'when a file exists' do context 'when a file exists' do
it 'does not use the default file' do it 'does not use the default file' do
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Terraform::StatePolicy do
let_it_be(:project) { create(:project) }
let_it_be(:terraform_state) { create(:terraform_state, project: project)}
subject { described_class.new(user, terraform_state) }
describe 'rules' do
context 'no access' do
let(:user) { create(:user) }
it { is_expected.to be_disallowed(:read_terraform_state) }
it { is_expected.to be_disallowed(:admin_terraform_state) }
end
context 'developer' do
let(:user) { create(:user, developer_projects: [project]) }
it { is_expected.to be_allowed(:read_terraform_state) }
it { is_expected.to be_disallowed(:admin_terraform_state) }
end
context 'maintainer' do
let(:user) { create(:user, maintainer_projects: [project]) }
it { is_expected.to be_allowed(:read_terraform_state) }
it { is_expected.to be_allowed(:admin_terraform_state) }
end
end
end
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