Commit 64a57a20 authored by Mark Chao's avatar Mark Chao

Merge branch '10io-graphql-mutation-delete-container-tags' into 'master'

Add GraphQL API to delete container repository tags

See merge request gitlab-org/gitlab!48617
parents cef6eb7e d8927d0f
......@@ -2,9 +2,7 @@
module Mutations
module ContainerRepositories
class Destroy < Mutations::BaseMutation
include ::Mutations::PackageEventable
class Destroy < ::Mutations::ContainerRepositories::DestroyBase
graphql_name 'DestroyContainerRepository'
authorize :destroy_container_image
......@@ -31,15 +29,6 @@ module Mutations
errors: []
}
end
private
def find_object(id:)
# TODO: remove this line when the compatibility layer is removed
# See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
id = ::Types::GlobalIDType[::ContainerRepository].coerce_isolated_input(id)
GitlabSchema.find_by_gid(id)
end
end
end
end
# frozen_string_literal: true
module Mutations
module ContainerRepositories
class DestroyBase < Mutations::BaseMutation
include ::Mutations::PackageEventable
private
def find_object(id:)
# TODO: remove this line when the compatibility layer is removed
# See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
id = ::Types::GlobalIDType[::ContainerRepository].coerce_isolated_input(id)
GitlabSchema.find_by_gid(id)
end
end
end
end
# frozen_string_literal: true
module Mutations
module ContainerRepositories
class DestroyTags < ::Mutations::ContainerRepositories::DestroyBase
LIMIT = 20.freeze
TOO_MANY_TAGS_ERROR_MESSAGE = "Number of tags is greater than #{LIMIT}"
graphql_name 'DestroyContainerRepositoryTags'
authorize :destroy_container_image
argument :id,
::Types::GlobalIDType[::ContainerRepository],
required: true,
description: 'ID of the container repository.'
argument :tag_names,
[GraphQL::STRING_TYPE],
required: true,
description: "Container repository tag(s) to delete. Total number can't be greater than #{LIMIT}",
prepare: ->(tag_names, _) do
raise Gitlab::Graphql::Errors::ArgumentError, TOO_MANY_TAGS_ERROR_MESSAGE if tag_names.size > LIMIT
tag_names
end
field :deleted_tag_names,
[GraphQL::STRING_TYPE],
description: 'Deleted container repository tags',
null: false
def resolve(id:, tag_names:)
container_repository = authorized_find!(id: id)
result = ::Projects::ContainerRepository::DeleteTagsService
.new(container_repository.project, current_user, tags: tag_names)
.execute(container_repository)
track_event(:delete_tag_bulk, :tag) if result[:status] == :success
{
errors: Array(result[:message]),
deleted_tag_names: result[:deleted] || []
}
end
end
end
end
......@@ -87,6 +87,7 @@ module Types
mount_mutation Mutations::DesignManagement::Move
mount_mutation Mutations::ContainerExpirationPolicies::Update
mount_mutation Mutations::ContainerRepositories::Destroy
mount_mutation Mutations::ContainerRepositories::DestroyTags
mount_mutation Mutations::Ci::PipelineCancel
mount_mutation Mutations::Ci::PipelineDestroy
mount_mutation Mutations::Ci::PipelineRetry
......
---
title: Add GraphQL API to delete container repository tags
merge_request: 48617
author:
type: added
......@@ -6615,6 +6615,46 @@ type DestroyContainerRepositoryPayload {
errors: [String!]!
}
"""
Autogenerated input type of DestroyContainerRepositoryTags
"""
input DestroyContainerRepositoryTagsInput {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
ID of the container repository.
"""
id: ContainerRepositoryID!
"""
Container repository tag(s) to delete. Total number can't be greater than 20
"""
tagNames: [String!]!
}
"""
Autogenerated return type of DestroyContainerRepositoryTags
"""
type DestroyContainerRepositoryTagsPayload {
"""
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
"""
Deleted container repository tags
"""
deletedTagNames: [String!]!
"""
Errors encountered during execution of the mutation.
"""
errors: [String!]!
}
"""
Autogenerated input type of DestroyNote
"""
......@@ -14284,6 +14324,7 @@ type Mutation {
destroyBoardList(input: DestroyBoardListInput!): DestroyBoardListPayload
destroyComplianceFramework(input: DestroyComplianceFrameworkInput!): DestroyComplianceFrameworkPayload
destroyContainerRepository(input: DestroyContainerRepositoryInput!): DestroyContainerRepositoryPayload
destroyContainerRepositoryTags(input: DestroyContainerRepositoryTagsInput!): DestroyContainerRepositoryTagsPayload
destroyNote(input: DestroyNoteInput!): DestroyNotePayload
destroySnippet(input: DestroySnippetInput!): DestroySnippetPayload
......
......@@ -18334,6 +18334,142 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "DestroyContainerRepositoryTagsInput",
"description": "Autogenerated input type of DestroyContainerRepositoryTags",
"fields": null,
"inputFields": [
{
"name": "id",
"description": "ID of the container repository.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ContainerRepositoryID",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "tagNames",
"description": "Container repository tag(s) to delete. Total number can't be greater than 20",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"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": "DestroyContainerRepositoryTagsPayload",
"description": "Autogenerated return type of DestroyContainerRepositoryTags",
"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": "deletedTagNames",
"description": "Deleted container repository tags",
"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
},
{
"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": "DestroyNoteInput",
......@@ -40741,6 +40877,33 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "destroyContainerRepositoryTags",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "DestroyContainerRepositoryTagsInput",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "DestroyContainerRepositoryTagsPayload",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "destroyNote",
"description": null,
......@@ -1118,6 +1118,16 @@ Autogenerated return type of DestroyContainerRepository.
| `containerRepository` | ContainerRepository! | The container repository policy after scheduling the deletion. |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
### DestroyContainerRepositoryTagsPayload
Autogenerated return type of DestroyContainerRepositoryTags.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `clientMutationId` | String | A unique identifier for the client performing the mutation. |
| `deletedTagNames` | String! => Array | Deleted container repository tags |
| `errors` | String! => Array | Errors encountered during execution of the mutation. |
### DestroyNotePayload
Autogenerated return type of DestroyNote.
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::ContainerRepositories::DestroyTags do
include_context 'container repository delete tags service shared context'
using RSpec::Parameterized::TableSyntax
let(:id) { repository.to_global_id.to_s }
specify { expect(described_class).to require_graphql_authorizations(:destroy_container_image) }
describe '#resolve' do
let(:tags) { %w[A C D E] }
subject do
described_class.new(object: nil, context: { current_user: user }, field: nil)
.resolve(id: id, tag_names: tags)
end
shared_examples 'destroying container repository tags' do
before do
stub_delete_reference_requests(tags)
expect_delete_tag_by_names(tags)
allow_next_instance_of(ContainerRegistry::Client) do |client|
allow(client).to receive(:supports_tag_delete?).and_return(true)
end
end
it 'destroys the container repository tags' do
expect(Projects::ContainerRepository::DeleteTagsService)
.to receive(:new).and_call_original
expect(subject).to eq(errors: [], deleted_tag_names: tags)
end
it 'creates a package event' do
expect(::Packages::CreateEventService)
.to receive(:new).with(nil, user, event_name: :delete_tag_bulk, scope: :tag).and_call_original
expect { subject }.to change { ::Packages::Event.count }.by(1)
end
end
shared_examples 'denying access to container respository' do
it 'raises an error' do
expect(::Projects::ContainerRepository::DeleteTagsService).not_to receive(:new)
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'with valid id' do
where(:user_role, :shared_examples_name) do
:maintainer | 'destroying container repository tags'
:developer | 'destroying container repository tags'
:reporter | 'denying access to container respository'
:guest | 'denying access to container respository'
:anonymous | 'denying access to container respository'
end
with_them do
before do
project.send("add_#{user_role}", user) unless user_role == :anonymous
end
it_behaves_like params[:shared_examples_name]
end
end
context 'with invalid id' do
let(:id) { 'gid://gitlab/ContainerRepository/5555' }
it_behaves_like 'denying access to container respository'
end
context 'with service error' do
before do
project.add_maintainer(user)
allow_next_instance_of(Projects::ContainerRepository::DeleteTagsService) do |service|
allow(service).to receive(:execute).and_return(message: 'could not delete tags', status: :error)
end
end
it { is_expected.to eq(errors: ['could not delete tags'], deleted_tag_names: []) }
it 'does not create a package event' do
expect(::Packages::CreateEventService).not_to receive(:new)
expect { subject }.not_to change { ::Packages::Event.count }
end
end
end
end
......@@ -22,7 +22,7 @@ RSpec.describe 'Destroying a container repository' do
GQL
end
let(:params) { { id: container_repository.to_global_id.to_s } }
let(:params) { { id: id } }
let(:mutation) { graphql_mutation(:destroy_container_repository, params, query) }
let(:mutation_response) { graphql_mutation_response(:destroyContainerRepository) }
let(:container_repository_mutation_response) { mutation_response['containerRepository'] }
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Destroying a container repository tags' do
include_context 'container repository delete tags service shared context'
using RSpec::Parameterized::TableSyntax
include GraphqlHelpers
let(:id) { repository.to_global_id.to_s }
let(:tags) { %w[A C D E] }
let(:query) do
<<~GQL
deletedTagNames
errors
GQL
end
let(:params) { { id: id, tag_names: tags } }
let(:mutation) { graphql_mutation(:destroy_container_repository_tags, params, query) }
let(:mutation_response) { graphql_mutation_response(:destroyContainerRepositoryTags) }
let(:tag_names_response) { mutation_response['deletedTagNames'] }
let(:errors_response) { mutation_response['errors'] }
shared_examples 'destroying the container repository tags' do
before do
stub_delete_reference_requests(tags)
expect_delete_tag_by_names(tags)
allow_next_instance_of(ContainerRegistry::Client) do |client|
allow(client).to receive(:supports_tag_delete?).and_return(true)
end
end
it 'destroys the container repository tags' do
expect(Projects::ContainerRepository::DeleteTagsService)
.to receive(:new).and_call_original
expect { subject }.to change { ::Packages::Event.count }.by(1)
expect(tag_names_response).to eq(tags)
expect(errors_response).to eq([])
end
it_behaves_like 'returning response status', :success
end
shared_examples 'denying the mutation request' do
it 'does not destroy the container repository tags' do
expect(Projects::ContainerRepository::DeleteTagsService)
.not_to receive(:new)
expect { subject }.not_to change { ::Packages::Event.count }
expect(mutation_response).to be_nil
end
it_behaves_like 'returning response status', :success
end
describe 'post graphql mutation' do
subject { post_graphql_mutation(mutation, current_user: user) }
context 'with valid id' do
where(:user_role, :shared_examples_name) do
:maintainer | 'destroying the container repository tags'
:developer | 'destroying the container repository tags'
:reporter | 'denying the mutation request'
:guest | 'denying the mutation request'
:anonymous | 'denying the mutation request'
end
with_them do
before do
project.send("add_#{user_role}", user) unless user_role == :anonymous
end
it_behaves_like params[:shared_examples_name]
end
end
context 'with invalid id' do
let(:id) { 'gid://gitlab/ContainerRepository/5555' }
it_behaves_like 'denying the mutation request'
end
context 'with too many tags' do
let(:tags) { Array.new(Mutations::ContainerRepositories::DestroyTags::LIMIT + 1, 'x') }
it 'returns too many tags error' do
expect { subject }.not_to change { ::Packages::Event.count }
explanation = graphql_errors.dig(0, 'extensions', 'problems', 0, 'explanation')
expect(explanation).to eq(Mutations::ContainerRepositories::DestroyTags::TOO_MANY_TAGS_ERROR_MESSAGE)
end
end
context 'with service error' do
before do
project.add_maintainer(user)
allow_next_instance_of(Projects::ContainerRepository::DeleteTagsService) do |service|
allow(service).to receive(:execute).and_return(message: 'could not delete tags', status: :error)
end
end
it 'returns an error' do
subject
expect(tag_names_response).to eq([])
expect(errors_response).to eq(['could not delete tags'])
end
it 'does not create a package event' do
expect(::Packages::CreateEventService).not_to receive(:new)
expect { subject }.not_to change { ::Packages::Event.count }
end
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