Commit 77e10cae authored by Tiger's avatar Tiger

Add GraphQL endpoint for Terraform state metadata

Allows fetching details of terraform states associated
with a project. The state file itself is not included,
as this will be requested on an individual state basis.
parent 08786510
# 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
description: 'Title of the label'
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:)
BatchLoader::GraphQL.for(title).batch(key: project) do |titles, loader, args|
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
......@@ -336,6 +336,8 @@ class Project < ApplicationRecord
has_many :webide_pipelines, -> { webide_source }, class_name: 'Ci::Pipeline', inverse_of: :project
has_many :reviews, inverse_of: :project
has_many :terraform_states, class_name: 'Terraform::State', inverse_of: :project
# GitLab Pages
has_many :pages_domains
has_one :pages_metadatum, class_name: 'ProjectPagesMetadatum', inverse_of: :project
......
......@@ -15,6 +15,7 @@ module Terraform
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 :ordered_by_name, -> { order(:name) }
validates :project_id, presence: true
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
......@@ -13953,6 +13953,31 @@ type Project {
"""
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
"""
......@@ -17482,6 +17507,73 @@ type TaskCompletionStatus {
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 sync and verification state of a terraform state
"""
......
......@@ -40498,6 +40498,59 @@
"isDeprecated": false,
"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",
"description": "Permissions for the current user on the resource",
......@@ -50929,6 +50982,231 @@
"enumValues": 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",
"name": "TerraformStateRegistry",
......@@ -2455,6 +2455,17 @@ Completion status of tasks.
| `completedCount` | Int! | Number of completed 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
Represents the 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
environment boards jira_import_status jira_imports services releases release
alert_management_alerts alert_management_alert alert_management_alert_status_counts
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)
......@@ -154,5 +154,12 @@ RSpec.describe GitlabSchema.types['Project'] do
it { is_expected.to have_graphql_type(Types::ContainerExpirationPolicyType) }
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'
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
......@@ -536,6 +536,7 @@ project:
- vulnerability_historical_statistics
- product_analytics_events
- pipeline_artifacts
- terraform_states
award_emoji:
- awardable
- user
......
......@@ -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(:package_files).class_name('Packages::PackageFile') }
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
it { is_expected.to have_many(:pages_domains) }
......
......@@ -18,6 +18,23 @@ RSpec.describe Terraform::State do
stub_terraform_state_object_storage
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
context 'when a file exists' 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