Commit be296c19 authored by Markus Koller's avatar Markus Koller

Merge branch '10io-graphql-query-container-repositories' into 'master'

GraphQL API for container repositories

See merge request gitlab-org/gitlab!44926
parents ade7e15c 11c8180f
# frozen_string_literal: true
module Resolvers
class ContainerRepositoriesResolver < BaseResolver
type Types::ContainerRepositoryType, null: true
argument :name, GraphQL::STRING_TYPE,
required: false,
description: 'Filter the container repositories by their name'
def resolve(name: nil)
ContainerRepositoriesFinder.new(user: current_user, subject: object, params: { name: name })
.execute
.tap { track_event(:list_repositories, :container) }
end
private
def track_event(event, scope)
::Packages::CreateEventService.new(nil, current_user, event_name: event, scope: scope).execute
::Gitlab::Tracking.event(event.to_s, scope.to_s)
end
end
end
# frozen_string_literal: true
module Types
class ContainerRepositoryStatusEnum < BaseEnum
graphql_name 'ContainerRepositoryStatus'
description 'Status of a container repository'
::ContainerRepository.statuses.keys.each do |status|
value status.upcase, value: status, description: "#{status.titleize} status."
end
end
end
# frozen_string_literal: true
module Types
class ContainerRepositoryType < BaseObject
graphql_name 'ContainerRepository'
description 'A container repository'
authorize :read_container_image
field :id, GraphQL::ID_TYPE, null: false, description: 'ID of the container repository.'
field :name, GraphQL::STRING_TYPE, null: false, description: 'Name of the container repository.'
field :path, GraphQL::STRING_TYPE, null: false, description: 'Path of the container repository.'
field :location, GraphQL::STRING_TYPE, null: false, description: 'URL of the container repository.'
field :created_at, Types::TimeType, null: false, description: 'Timestamp when the container repository was created.'
field :updated_at, Types::TimeType, null: false, description: 'Timestamp when the container repository was updated.'
field :expiration_policy_started_at, Types::TimeType, null: true, description: 'Timestamp when the cleanup done by the expiration policy was started on the container repository.'
field :status, Types::ContainerRepositoryStatusEnum, null: true, description: 'Status of the container repository.'
field :tags_count, GraphQL::INT_TYPE, null: false, description: 'Number of tags associated with this image.'
field :can_delete, GraphQL::BOOLEAN_TYPE, null: false, description: 'Can the current user delete the container repository.'
def can_delete
Ability.allowed?(current_user, :update_container_image, object)
end
end
end
......@@ -87,6 +87,13 @@ module Types
extras: [:lookahead],
resolver: Resolvers::GroupMembersResolver
field :container_repositories,
Types::ContainerRepositoryType.connection_type,
null: true,
description: 'Container repositories of the project',
resolver: Resolvers::ContainerRepositoriesResolver,
authorize: :read_container_image
def label(title:)
BatchLoader::GraphQL.for(title).batch(key: group) do |titles, loader, args|
LabelsFinder
......
......@@ -285,6 +285,13 @@ module Types
null: true,
description: 'The container expiration policy of the project'
field :container_repositories,
Types::ContainerRepositoryType.connection_type,
null: true,
description: 'Container repositories of the project',
resolver: Resolvers::ContainerRepositoriesResolver,
authorize: :read_container_image
field :label,
Types::LabelType,
null: true,
......
---
title: GraphQL API for listing container repositories
merge_request: 44926
author:
type: added
......@@ -2987,6 +2987,111 @@ enum ContainerExpirationPolicyOlderThanEnum {
THIRTY_DAYS
}
"""
A container repository
"""
type ContainerRepository {
"""
Can the current user delete the container repository.
"""
canDelete: Boolean!
"""
Timestamp when the container repository was created.
"""
createdAt: Time!
"""
Timestamp when the cleanup done by the expiration policy was started on the container repository.
"""
expirationPolicyStartedAt: Time
"""
ID of the container repository.
"""
id: ID!
"""
URL of the container repository.
"""
location: String!
"""
Name of the container repository.
"""
name: String!
"""
Path of the container repository.
"""
path: String!
"""
Status of the container repository.
"""
status: ContainerRepositoryStatus
"""
Number of tags associated with this image.
"""
tagsCount: Int!
"""
Timestamp when the container repository was updated.
"""
updatedAt: Time!
}
"""
The connection type for ContainerRepository.
"""
type ContainerRepositoryConnection {
"""
A list of edges.
"""
edges: [ContainerRepositoryEdge]
"""
A list of nodes.
"""
nodes: [ContainerRepository]
"""
Information to aid in pagination.
"""
pageInfo: PageInfo!
}
"""
An edge in a connection.
"""
type ContainerRepositoryEdge {
"""
A cursor for use in pagination.
"""
cursor: String!
"""
The item at the end of the edge.
"""
node: ContainerRepository
}
"""
Status of a container repository
"""
enum ContainerRepositoryStatus {
"""
Delete Failed status.
"""
DELETE_FAILED
"""
Delete Scheduled status.
"""
DELETE_SCHEDULED
}
"""
Autogenerated input type of CreateAlertIssue
"""
......@@ -7567,6 +7672,36 @@ type Group {
last: Int
): BoardConnection
"""
Container repositories of the project
"""
containerRepositories(
"""
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
"""
Filter the container repositories by their name
"""
name: String
): ContainerRepositoryConnection
"""
Includes at least one project where the repository size exceeds the limit
"""
......@@ -13588,6 +13723,36 @@ type Project {
"""
containerRegistryEnabled: Boolean
"""
Container repositories of the project
"""
containerRepositories(
"""
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
"""
Filter the container repositories by their name
"""
name: String
): ContainerRepositoryConnection
"""
Timestamp of the project creation
"""
......
......@@ -455,6 +455,23 @@ A tag expiration policy designed to keep only the images that matter most.
| `olderThan` | ContainerExpirationPolicyOlderThanEnum | Tags older that this will expire |
| `updatedAt` | Time! | Timestamp of when the container expiration policy was updated |
### ContainerRepository
A container repository.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `canDelete` | Boolean! | Can the current user delete the container repository. |
| `createdAt` | Time! | Timestamp when the container repository was created. |
| `expirationPolicyStartedAt` | Time | Timestamp when the cleanup done by the expiration policy was started on the container repository. |
| `id` | ID! | ID of the container repository. |
| `location` | String! | URL of the container repository. |
| `name` | String! | Name of the container repository. |
| `path` | String! | Path of the container repository. |
| `status` | ContainerRepositoryStatus | Status of the container repository. |
| `tagsCount` | Int! | Number of tags associated with this image. |
| `updatedAt` | Time! | Timestamp when the container repository was updated. |
### CreateAlertIssuePayload
Autogenerated return type of CreateAlertIssue.
......@@ -3244,6 +3261,15 @@ Mode of a commit action.
| `SEVEN_DAYS` | 7 days until tags are automatically removed |
| `THIRTY_DAYS` | 30 days until tags are automatically removed |
### ContainerRepositoryStatus
Status of a container repository.
| Value | Description |
| ----- | ----------- |
| `DELETE_FAILED` | Delete Failed status. |
| `DELETE_SCHEDULED` | Delete Scheduled status. |
### DastScanTypeEnum
| Value | Description |
......
......@@ -13,6 +13,14 @@ FactoryBot.define do
name { '' }
end
trait :status_delete_scheduled do
status { :delete_scheduled }
end
trait :status_delete_failed do
status { :delete_failed }
end
after(:build) do |repository, evaluator|
next if evaluator.tags.to_a.none?
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Resolvers::ContainerRepositoriesResolver do
include GraphqlHelpers
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be_with_reload(:project) { create(:project, group: group) }
let_it_be(:container_repositories) { create(:container_repository, project: project) }
let(:args) { {} }
describe '#resolve' do
let(:object) { project }
subject { resolve(described_class, ctx: { current_user: user }, args: args, obj: object) }
shared_examples 'returning container repositories' do
it { is_expected.to contain_exactly(container_repositories) }
context 'with a named search' do
let_it_be(:named_container_repository) { create(:container_repository, project: project, name: 'Foobar') }
let(:args) { { name: 'ooba' } }
it { is_expected.to contain_exactly(named_container_repository) }
end
end
context 'with authorized user' do
before do
group.add_user(user, :maintainer)
end
context 'when the object is a project' do
it_behaves_like 'returning container repositories'
end
context 'when the object is a group' do
let(:object) { group }
it_behaves_like 'returning container repositories'
end
context 'when the object is an invalid type' do
let(:object) { Object.new }
it { expect { subject }.to raise_exception('invalid subject_type') }
end
end
context 'with unauthorized user' do
it { is_expected.to be nil }
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['ContainerRepositoryStatus'] do
it 'exposes all statuses' do
expect(described_class.values.keys).to contain_exactly(*ContainerRepository.statuses.keys.map(&:upcase))
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['ContainerRepository'] do
fields = %i[id name path location created_at updated_at expiration_policy_started_at status tags_count can_delete]
it { expect(described_class.graphql_name).to eq('ContainerRepository') }
it { expect(described_class.description).to eq('A container repository') }
it { expect(described_class).to require_graphql_authorizations(:read_container_image) }
it { expect(described_class).to have_graphql_fields(fields) }
describe 'status field' do
subject { described_class.fields['status'] }
it 'returns status enum' do
is_expected.to have_graphql_type(Types::ContainerRepositoryStatusEnum)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'getting container repositories in a group' do
using RSpec::Parameterized::TableSyntax
include GraphqlHelpers
let_it_be(:owner) { create(:user) }
let_it_be_with_reload(:group) { create(:group) }
let_it_be_with_reload(:project) { create(:project, group: group) }
let_it_be(:container_repository) { create(:container_repository, project: project) }
let_it_be(:container_repositories_delete_scheduled) { create_list(:container_repository, 2, :status_delete_scheduled, project: project) }
let_it_be(:container_repositories_delete_failed) { create_list(:container_repository, 2, :status_delete_failed, project: project) }
let_it_be(:container_repositories) { [container_repository, container_repositories_delete_scheduled, container_repositories_delete_failed].flatten }
let_it_be(:container_expiration_policy) { project.container_expiration_policy }
let(:fields) do
<<~GQL
edges {
node {
#{all_graphql_fields_for('container_repositories'.classify)}
}
}
GQL
end
let(:query) do
graphql_query_for(
'group',
{ 'fullPath' => group.full_path },
query_graphql_field('container_repositories', {}, fields)
)
end
let(:user) { owner }
let(:variables) { {} }
let(:container_repositories_response) { graphql_data.dig('group', 'containerRepositories', 'edges') }
before do
group.add_owner(owner)
stub_container_registry_config(enabled: true)
container_repositories.each do |repository|
stub_container_registry_tags(repository: repository.path, tags: %w(tag1 tag2 tag3), with_manifest: false)
end
end
subject { post_graphql(query, current_user: user, variables: variables) }
it_behaves_like 'a working graphql query' do
before do
subject
end
end
context 'with different permissions' do
let_it_be(:user) { create(:user) }
where(:group_visibility, :role, :access_granted, :can_delete) do
:private | :maintainer | true | true
:private | :developer | true | true
:private | :reporter | true | false
:private | :guest | false | false
:private | :anonymous | false | false
:public | :maintainer | true | true
:public | :developer | true | true
:public | :reporter | true | false
:public | :guest | false | false
:public | :anonymous | false | false
end
with_them do
before do
group.update!(visibility_level: Gitlab::VisibilityLevel.const_get(group_visibility.to_s.upcase, false))
project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(group_visibility.to_s.upcase, false))
group.add_user(user, role) unless role == :anonymous
end
it 'return the proper response' do
subject
if access_granted
expect(container_repositories_response.size).to eq(container_repositories.size)
container_repositories_response.each do |repository_response|
expect(repository_response.dig('node', 'canDelete')).to eq(can_delete)
end
else
expect(container_repositories_response).to eq(nil)
end
end
end
end
context 'limiting the number of repositories' do
let(:issue_limit) { 1 }
let(:variables) do
{ path: group.full_path, n: issue_limit }
end
let(:query) do
<<~GQL
query($path: ID!, $n: Int) {
group(fullPath: $path) {
containerRepositories(first: $n) { #{fields} }
}
}
GQL
end
it 'only returns N issues' do
subject
expect(container_repositories_response.size).to eq(issue_limit)
end
end
context 'filter by name' do
let_it_be(:container_repository) { create(:container_repository, name: 'fooBar', project: project) }
let(:name) { 'ooba' }
let(:query) do
<<~GQL
query($path: ID!, $name: String) {
group(fullPath: $path) {
containerRepositories(name: $name) { #{fields} }
}
}
GQL
end
let(:variables) do
{ path: group.full_path, name: name }
end
before do
stub_container_registry_tags(repository: container_repository.path, tags: %w(tag4 tag5 tag6), with_manifest: false)
end
it 'returns the searched container repository' do
subject
expect(container_repositories_response.size).to eq(1)
expect(container_repositories_response.first.dig('node', 'id')).to eq(container_repository.to_global_id.to_s)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'getting container repositories in a project' do
using RSpec::Parameterized::TableSyntax
include GraphqlHelpers
let_it_be_with_reload(:project) { create(:project, :private) }
let_it_be(:container_repository) { create(:container_repository, project: project) }
let_it_be(:container_repositories_delete_scheduled) { create_list(:container_repository, 2, :status_delete_scheduled, project: project) }
let_it_be(:container_repositories_delete_failed) { create_list(:container_repository, 2, :status_delete_failed, project: project) }
let_it_be(:container_repositories) { [container_repository, container_repositories_delete_scheduled, container_repositories_delete_failed].flatten }
let_it_be(:container_expiration_policy) { project.container_expiration_policy }
let(:fields) do
<<~GQL
edges {
node {
#{all_graphql_fields_for('container_repositories'.classify)}
}
}
GQL
end
let(:query) do
graphql_query_for(
'project',
{ 'fullPath' => project.full_path },
query_graphql_field('container_repositories', {}, fields)
)
end
let(:user) { project.owner }
let(:variables) { {} }
let(:container_repositories_response) { graphql_data.dig('project', 'containerRepositories', 'edges') }
before do
stub_container_registry_config(enabled: true)
container_repositories.each do |repository|
stub_container_registry_tags(repository: repository.path, tags: %w(tag1 tag2 tag3), with_manifest: false)
end
end
subject { post_graphql(query, current_user: user, variables: variables) }
it_behaves_like 'a working graphql query' do
before do
subject
end
end
context 'with different permissions' do
let_it_be(:user) { create(:user) }
where(:project_visibility, :role, :access_granted, :can_delete) do
:private | :maintainer | true | true
:private | :developer | true | true
:private | :reporter | true | false
:private | :guest | false | false
:private | :anonymous | false | false
:public | :maintainer | true | true
:public | :developer | true | true
:public | :reporter | true | false
:public | :guest | true | false
:public | :anonymous | true | false
end
with_them do
before do
project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(project_visibility.to_s.upcase, false))
project.add_user(user, role) unless role == :anonymous
end
it 'return the proper response' do
subject
if access_granted
expect(container_repositories_response.size).to eq(container_repositories.size)
container_repositories_response.each do |repository_response|
expect(repository_response.dig('node', 'canDelete')).to eq(can_delete)
end
else
expect(container_repositories_response).to eq(nil)
end
end
end
end
context 'limiting the number of repositories' do
let(:issue_limit) { 1 }
let(:variables) do
{ path: project.full_path, n: issue_limit }
end
let(:query) do
<<~GQL
query($path: ID!, $n: Int) {
project(fullPath: $path) {
containerRepositories(first: $n) { #{fields} }
}
}
GQL
end
it 'only returns N issues' do
subject
expect(container_repositories_response.size).to eq(issue_limit)
end
end
context 'filter by name' do
let_it_be(:container_repository) { create(:container_repository, name: 'fooBar', project: project) }
let(:name) { 'ooba' }
let(:query) do
<<~GQL
query($path: ID!, $name: String) {
project(fullPath: $path) {
containerRepositories(name: $name) { #{fields} }
}
}
GQL
end
let(:variables) do
{ path: project.full_path, name: name }
end
before do
stub_container_registry_tags(repository: container_repository.path, tags: %w(tag4 tag5 tag6), with_manifest: false)
end
it 'returns the searched container repository' do
subject
expect(container_repositories_response.size).to eq(1)
expect(container_repositories_response.first.dig('node', 'id')).to eq(container_repository.to_global_id.to_s)
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