Commit 76a1f5b2 authored by Rémy Coutable's avatar Rémy Coutable

Merge branch '216571-terraform-state-mutations' into 'master'

Add GraphQL endpoints to lock, unlock and delete Terraform states

See merge request gitlab-org/gitlab!43955
parents e429e473 188aad96
# frozen_string_literal: true
module Mutations
module Terraform
module State
class Base < BaseMutation
authorize :admin_terraform_state
argument :id,
Types::GlobalIDType[::Terraform::State],
required: true,
description: 'Global ID of the Terraform state'
private
def find_object(id:)
GitlabSchema.find_by_gid(id)
end
end
end
end
end
# frozen_string_literal: true
module Mutations
module Terraform
module State
class Delete < Base
graphql_name 'TerraformStateDelete'
def resolve(id:)
state = authorized_find!(id: id)
state.destroy
{ errors: errors_on_object(state) }
end
end
end
end
end
# frozen_string_literal: true
module Mutations
module Terraform
module State
class Lock < Base
graphql_name 'TerraformStateLock'
def resolve(id:)
state = authorized_find!(id: id)
if state.locked?
state.errors.add(:base, 'state is already locked')
else
state.update(lock_xid: lock_xid, locked_by_user: current_user, locked_at: Time.current)
end
{ errors: errors_on_object(state) }
end
private
def lock_xid
SecureRandom.uuid
end
end
end
end
end
# frozen_string_literal: true
module Mutations
module Terraform
module State
class Unlock < Base
graphql_name 'TerraformStateUnlock'
def resolve(id:)
state = authorized_find!(id: id)
state.update(lock_xid: nil, locked_by_user: nil, locked_at: nil)
{ errors: errors_on_object(state) }
end
end
end
end
end
...@@ -54,6 +54,9 @@ module Types ...@@ -54,6 +54,9 @@ module Types
'If the body of the Note contains only quick actions, the Note will be ' \ 'If the body of the Note contains only quick actions, the Note will be ' \
'destroyed during the update, and no Note will be returned' 'destroyed during the update, and no Note will be returned'
mount_mutation Mutations::Notes::Destroy mount_mutation Mutations::Notes::Destroy
mount_mutation Mutations::Terraform::State::Delete
mount_mutation Mutations::Terraform::State::Lock
mount_mutation Mutations::Terraform::State::Unlock
mount_mutation Mutations::Todos::MarkDone mount_mutation Mutations::Todos::MarkDone
mount_mutation Mutations::Todos::Restore mount_mutation Mutations::Todos::Restore
mount_mutation Mutations::Todos::MarkAllDone mount_mutation Mutations::Todos::MarkAllDone
......
---
title: Add GraphQL endpoints to lock, unlock and delete Terraform states
merge_request: 43955
author:
type: added
...@@ -12531,6 +12531,9 @@ type Mutation { ...@@ -12531,6 +12531,9 @@ type Mutation {
removeProjectFromSecurityDashboard(input: RemoveProjectFromSecurityDashboardInput!): RemoveProjectFromSecurityDashboardPayload removeProjectFromSecurityDashboard(input: RemoveProjectFromSecurityDashboardInput!): RemoveProjectFromSecurityDashboardPayload
revertVulnerabilityToDetected(input: RevertVulnerabilityToDetectedInput!): RevertVulnerabilityToDetectedPayload @deprecated(reason: "Use vulnerabilityRevertToDetected. Deprecated in 13.5") revertVulnerabilityToDetected(input: RevertVulnerabilityToDetectedInput!): RevertVulnerabilityToDetectedPayload @deprecated(reason: "Use vulnerabilityRevertToDetected. Deprecated in 13.5")
runDastScan(input: RunDASTScanInput!): RunDASTScanPayload @deprecated(reason: "Use DastOnDemandScanCreate. Deprecated in 13.4") runDastScan(input: RunDASTScanInput!): RunDASTScanPayload @deprecated(reason: "Use DastOnDemandScanCreate. Deprecated in 13.4")
terraformStateDelete(input: TerraformStateDeleteInput!): TerraformStateDeletePayload
terraformStateLock(input: TerraformStateLockInput!): TerraformStateLockPayload
terraformStateUnlock(input: TerraformStateUnlockInput!): TerraformStateUnlockPayload
todoMarkDone(input: TodoMarkDoneInput!): TodoMarkDonePayload todoMarkDone(input: TodoMarkDoneInput!): TodoMarkDonePayload
todoRestore(input: TodoRestoreInput!): TodoRestorePayload todoRestore(input: TodoRestoreInput!): TodoRestorePayload
todoRestoreMany(input: TodoRestoreManyInput!): TodoRestoreManyPayload todoRestoreMany(input: TodoRestoreManyInput!): TodoRestoreManyPayload
...@@ -19010,6 +19013,36 @@ type TerraformStateConnection { ...@@ -19010,6 +19013,36 @@ type TerraformStateConnection {
pageInfo: PageInfo! pageInfo: PageInfo!
} }
"""
Autogenerated input type of TerraformStateDelete
"""
input TerraformStateDeleteInput {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Global ID of the Terraform state
"""
id: TerraformStateID!
}
"""
Autogenerated return type of TerraformStateDelete
"""
type TerraformStateDeletePayload {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Errors encountered during execution of the mutation.
"""
errors: [String!]!
}
""" """
An edge in a connection. An edge in a connection.
""" """
...@@ -19025,6 +19058,71 @@ type TerraformStateEdge { ...@@ -19025,6 +19058,71 @@ type TerraformStateEdge {
node: TerraformState node: TerraformState
} }
"""
Identifier of Terraform::State
"""
scalar TerraformStateID
"""
Autogenerated input type of TerraformStateLock
"""
input TerraformStateLockInput {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Global ID of the Terraform state
"""
id: TerraformStateID!
}
"""
Autogenerated return type of TerraformStateLock
"""
type TerraformStateLockPayload {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Errors encountered during execution of the mutation.
"""
errors: [String!]!
}
"""
Autogenerated input type of TerraformStateUnlock
"""
input TerraformStateUnlockInput {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Global ID of the Terraform state
"""
id: TerraformStateID!
}
"""
Autogenerated return type of TerraformStateUnlock
"""
type TerraformStateUnlockPayload {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Errors encountered during execution of the mutation.
"""
errors: [String!]!
}
""" """
Represents the Geo sync and verification state of a terraform state version Represents the Geo sync and verification state of a terraform state version
""" """
......
...@@ -36352,6 +36352,87 @@ ...@@ -36352,6 +36352,87 @@
"isDeprecated": true, "isDeprecated": true,
"deprecationReason": "Use DastOnDemandScanCreate. Deprecated in 13.4" "deprecationReason": "Use DastOnDemandScanCreate. Deprecated in 13.4"
}, },
{
"name": "terraformStateDelete",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "TerraformStateDeleteInput",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "TerraformStateDeletePayload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "terraformStateLock",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "TerraformStateLockInput",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "TerraformStateLockPayload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "terraformStateUnlock",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "TerraformStateUnlockInput",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "TerraformStateUnlockPayload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "todoMarkDone", "name": "todoMarkDone",
"description": null, "description": null,
...@@ -55103,6 +55184,94 @@ ...@@ -55103,6 +55184,94 @@
"enumValues": null, "enumValues": null,
"possibleTypes": null "possibleTypes": null
}, },
{
"kind": "INPUT_OBJECT",
"name": "TerraformStateDeleteInput",
"description": "Autogenerated input type of TerraformStateDelete",
"fields": null,
"inputFields": [
{
"name": "id",
"description": "Global ID of the Terraform state",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "TerraformStateID",
"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": "TerraformStateDeletePayload",
"description": "Autogenerated return type of TerraformStateDelete",
"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": "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": "OBJECT", "kind": "OBJECT",
"name": "TerraformStateEdge", "name": "TerraformStateEdge",
...@@ -55148,6 +55317,192 @@ ...@@ -55148,6 +55317,192 @@
"enumValues": null, "enumValues": null,
"possibleTypes": null "possibleTypes": null
}, },
{
"kind": "SCALAR",
"name": "TerraformStateID",
"description": "Identifier of Terraform::State",
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "TerraformStateLockInput",
"description": "Autogenerated input type of TerraformStateLock",
"fields": null,
"inputFields": [
{
"name": "id",
"description": "Global ID of the Terraform state",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "TerraformStateID",
"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": "TerraformStateLockPayload",
"description": "Autogenerated return type of TerraformStateLock",
"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": "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": "TerraformStateUnlockInput",
"description": "Autogenerated input type of TerraformStateUnlock",
"fields": null,
"inputFields": [
{
"name": "id",
"description": "Global ID of the Terraform state",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "TerraformStateID",
"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": "TerraformStateUnlockPayload",
"description": "Autogenerated return type of TerraformStateUnlock",
"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": "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": "OBJECT", "kind": "OBJECT",
"name": "TerraformStateVersionRegistry", "name": "TerraformStateVersionRegistry",
...@@ -2629,6 +2629,33 @@ Completion status of tasks. ...@@ -2629,6 +2629,33 @@ Completion status of tasks.
| `name` | String! | Name of the Terraform state | | `name` | String! | Name of the Terraform state |
| `updatedAt` | Time! | Timestamp the Terraform state was updated | | `updatedAt` | Time! | Timestamp the Terraform state was updated |
### TerraformStateDeletePayload
Autogenerated return type of TerraformStateDelete.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
### TerraformStateLockPayload
Autogenerated return type of TerraformStateLock.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
### TerraformStateUnlockPayload
Autogenerated return type of TerraformStateUnlock.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
### TerraformStateVersionRegistry ### TerraformStateVersionRegistry
Represents the Geo sync and verification state of a terraform state version. Represents the Geo sync and verification state of a terraform state version.
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::Terraform::State::Delete do
let_it_be(:user) { create(:user) }
let_it_be(:state) { create(:terraform_state) }
let(:mutation) do
described_class.new(
object: double,
context: { current_user: user },
field: double
)
end
it { expect(described_class.graphql_name).to eq('TerraformStateDelete') }
it { expect(described_class).to require_graphql_authorizations(:admin_terraform_state) }
describe '#resolve' do
let(:global_id) { state.to_global_id }
subject { mutation.resolve(id: global_id) }
context 'user does not have permission' do
it 'raises an error', :aggregate_failures do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
expect { state.reload }.not_to raise_error
end
end
context 'user has permission' do
before do
state.project.add_maintainer(user)
end
it 'deletes the state', :aggregate_failures do
expect do
expect(subject).to eq(errors: [])
end.to change { ::Terraform::State.count }.by(-1)
expect { state.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
end
context 'with invalid params' do
let(:global_id) { user.to_global_id }
it 'raises an error', :aggregate_failures do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
expect { state.reload }.not_to raise_error
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::Terraform::State::Lock do
let_it_be(:user) { create(:user) }
let_it_be(:state) { create(:terraform_state) }
let(:mutation) do
described_class.new(
object: double,
context: { current_user: user },
field: double
)
end
it { expect(described_class.graphql_name).to eq('TerraformStateLock') }
it { expect(described_class).to require_graphql_authorizations(:admin_terraform_state) }
describe '#resolve' do
let(:global_id) { state.to_global_id }
subject { mutation.resolve(id: global_id) }
context 'user does not have permission' do
it 'raises an error', :aggregate_failures do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
expect(state.reload).not_to be_locked
end
end
context 'user has permission' do
before do
state.project.add_maintainer(user)
end
it 'locks the state', :aggregate_failures do
expect(subject).to eq(errors: [])
expect(state.reload).to be_locked
expect(state.locked_by_user).to eq(user)
expect(state.lock_xid).to be_present
expect(state.locked_at).to be_present
end
context 'state is already locked' do
let(:locked_by_user) { create(:user) }
let(:state) { create(:terraform_state, :locked, locked_by_user: locked_by_user) }
it 'does not modify the existing lock', :aggregate_failures do
expect(subject).to eq(errors: ['state is already locked'])
expect(state.reload).to be_locked
expect(state.locked_by_user).to eq(locked_by_user)
end
end
end
context 'with invalid params' do
let(:global_id) { user.to_global_id }
it 'raises an error', :aggregate_failures do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
expect(state.reload).not_to be_locked
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::Terraform::State::Unlock do
let_it_be(:user) { create(:user) }
let_it_be(:state) { create(:terraform_state, :locked) }
let(:mutation) do
described_class.new(
object: double,
context: { current_user: user },
field: double
)
end
it { expect(described_class.graphql_name).to eq('TerraformStateUnlock') }
it { expect(described_class).to require_graphql_authorizations(:admin_terraform_state) }
describe '#resolve' do
let(:global_id) { state.to_global_id }
subject { mutation.resolve(id: global_id) }
context 'user does not have permission' do
it 'raises an error', :aggregate_failures do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
expect(state.reload).to be_locked
end
end
context 'user has permission' do
before do
state.project.add_maintainer(user)
end
it 'unlocks the state', :aggregate_failures do
expect(subject).to eq(errors: [])
expect(state.reload).not_to be_locked
end
context 'state is already unlocked' do
let(:state) { create(:terraform_state) }
it 'does not modify the state' do
expect(subject).to eq(errors: [])
expect(state.reload).not_to be_locked
end
end
end
context 'with invalid params' do
let(:global_id) { user.to_global_id }
it 'raises an error', :aggregate_failures do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
expect(state.reload).to be_locked
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'delete a terraform state' do
include GraphqlHelpers
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user, maintainer_projects: [project]) }
let(:state) { create(:terraform_state, project: project) }
let(:mutation) { graphql_mutation(:terraform_state_delete, id: state.to_global_id.to_s) }
before do
post_graphql_mutation(mutation, current_user: user)
end
include_examples 'a working graphql query'
it 'deletes the state' do
expect { state.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'lock a terraform state' do
include GraphqlHelpers
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user, maintainer_projects: [project]) }
let(:state) { create(:terraform_state, project: project) }
let(:mutation) { graphql_mutation(:terraform_state_lock, id: state.to_global_id.to_s) }
before do
expect(state).not_to be_locked
post_graphql_mutation(mutation, current_user: user)
end
include_examples 'a working graphql query'
it 'locks the state' do
expect(state.reload).to be_locked
expect(state.locked_by_user).to eq(user)
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'unlock a terraform state' do
include GraphqlHelpers
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user, maintainer_projects: [project]) }
let(:state) { create(:terraform_state, :locked, project: project) }
let(:mutation) { graphql_mutation(:terraform_state_unlock, id: state.to_global_id.to_s) }
before do
expect(state).to be_locked
post_graphql_mutation(mutation, current_user: user)
end
include_examples 'a working graphql query'
it 'unlocks the state' do
expect(state.reload).not_to be_locked
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