Commit c958c201 authored by Grzegorz Bizon's avatar Grzegorz Bizon

Merge branch '31050-registry-image-lists' into 'master'

Lazy load and paginate registry image list

Closes #31050

See merge request gitlab-org/gitlab-ce!14303
parents 912e6b0b ba4a4429
<script>
/* globals Flash */
import { mapGetters, mapActions } from 'vuex';
import '../../flash';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import store from '../stores';
import collapsibleContainer from './collapsible_container.vue';
import { errorMessages, errorMessagesTypes } from '../constants';
export default {
name: 'registryListApp',
props: {
endpoint: {
type: String,
required: true,
},
},
store,
components: {
collapsibleContainer,
loadingIcon,
},
computed: {
...mapGetters([
'isLoading',
'repos',
]),
},
methods: {
...mapActions([
'setMainEndpoint',
'fetchRepos',
]),
},
created() {
this.setMainEndpoint(this.endpoint);
},
mounted() {
this.fetchRepos()
.catch(() => Flash(errorMessages[errorMessagesTypes.FETCH_REPOS]));
},
};
</script>
<template>
<div>
<loading-icon
v-if="isLoading"
size="3"
/>
<collapsible-container
v-else-if="!isLoading && repos.length"
v-for="(item, index) in repos"
:key="index"
:repo="item"
/>
<p v-else-if="!isLoading && !repos.length">
{{__("No container images stored for this project. Add one by following the instructions above.")}}
</p>
</div>
</template>
<script>
/* globals Flash */
import { mapActions } from 'vuex';
import '../../flash';
import clipboardButton from '../../vue_shared/components/clipboard_button.vue';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import tooltip from '../../vue_shared/directives/tooltip';
import tableRegistry from './table_registry.vue';
import { errorMessages, errorMessagesTypes } from '../constants';
export default {
name: 'collapsibeContainerRegisty',
props: {
repo: {
type: Object,
required: true,
},
},
components: {
clipboardButton,
loadingIcon,
tableRegistry,
},
directives: {
tooltip,
},
data() {
return {
isOpen: false,
};
},
computed: {
clipboardText() {
return `docker pull ${this.repo.location}`;
},
},
methods: {
...mapActions([
'fetchRepos',
'fetchList',
'deleteRepo',
]),
toggleRepo() {
this.isOpen = !this.isOpen;
if (this.isOpen) {
this.fetchList({ repo: this.repo })
.catch(() => this.showError(errorMessagesTypes.FETCH_REGISTRY));
}
},
handleDeleteRepository() {
this.deleteRepo(this.repo)
.then(() => this.fetchRepos())
.catch(() => this.showError(errorMessagesTypes.DELETE_REPO));
},
showError(message) {
Flash((errorMessages[message]));
},
},
};
</script>
<template>
<div class="container-image">
<div
class="container-image-head">
<button
type="button"
@click="toggleRepo"
class="js-toggle-repo btn-link">
<i
class="fa"
:class="{
'fa-chevron-right': !isOpen,
'fa-chevron-up': isOpen,
}"
aria-hidden="true">
</i>
{{repo.name}}
</button>
<clipboard-button
v-if="repo.location"
:text="clipboardText"
:title="repo.location"
/>
<div class="controls hidden-xs pull-right">
<button
v-if="repo.canDelete"
type="button"
class="js-remove-repo btn btn-danger"
:title="s__('ContainerRegistry|Remove repository')"
:aria-label="s__('ContainerRegistry|Remove repository')"
v-tooltip
@click="handleDeleteRepository">
<i
class="fa fa-trash"
aria-hidden="true">
</i>
</button>
</div>
</div>
<loading-icon
v-if="repo.isLoading"
class="append-bottom-20"
size="2"
/>
<div
v-else-if="!repo.isLoading && isOpen"
class="container-image-tags">
<table-registry
v-if="repo.list.length"
:repo="repo"
/>
<div
v-else
class="nothing-here-block">
{{s__("ContainerRegistry|No tags in Container Registry for this container image.")}}
</div>
</div>
</div>
</template>
<script>
/* globals Flash */
import { mapActions } from 'vuex';
import { n__ } from '../../locale';
import '../../flash';
import clipboardButton from '../../vue_shared/components/clipboard_button.vue';
import tablePagination from '../../vue_shared/components/table_pagination.vue';
import tooltip from '../../vue_shared/directives/tooltip';
import timeagoMixin from '../../vue_shared/mixins/timeago';
import { errorMessages, errorMessagesTypes } from '../constants';
export default {
props: {
repo: {
type: Object,
required: true,
},
},
components: {
clipboardButton,
tablePagination,
},
mixins: [
timeagoMixin,
],
directives: {
tooltip,
},
computed: {
shouldRenderPagination() {
return this.repo.pagination.total > this.repo.pagination.perPage;
},
},
methods: {
...mapActions([
'fetchList',
'deleteRegistry',
]),
layers(item) {
return item.layers ? n__('%d layer', '%d layers', item.layers) : '';
},
handleDeleteRegistry(registry) {
this.deleteRegistry(registry)
.then(() => this.fetchList({ repo: this.repo }))
.catch(() => this.showError(errorMessagesTypes.DELETE_REGISTRY));
},
onPageChange(pageNumber) {
this.fetchList({ repo: this.repo, page: pageNumber })
.catch(() => this.showError(errorMessagesTypes.FETCH_REGISTRY));
},
clipboardText(text) {
return `docker pull ${text}`;
},
showError(message) {
Flash((errorMessages[message]));
},
},
};
</script>
<template>
<div>
<table class="table tags">
<thead>
<tr>
<th>{{s__('ContainerRegistry|Tag')}}</th>
<th>{{s__('ContainerRegistry|Tag ID')}}</th>
<th>{{s__("ContainerRegistry|Size")}}</th>
<th>{{s__("ContainerRegistry|Created")}}</th>
<th></th>
</tr>
</thead>
<tbody>
<tr
v-for="(item, i) in repo.list"
:key="i">
<td>
{{item.tag}}
<clipboard-button
v-if="item.location"
:title="item.location"
:text="clipboardText(item.location)"
/>
</td>
<td>
<span
v-tooltip
:title="item.revision"
data-placement="bottom">
{{item.shortRevision}}
</span>
</td>
<td>
{{item.size}}
<template v-if="item.size && item.layers">
&middot;
</template>
{{layers(item)}}
</td>
<td>
{{timeFormated(item.createdAt)}}
</td>
<td class="content">
<button
v-if="item.canDelete"
type="button"
class="js-delete-registry btn btn-danger hidden-xs pull-right"
:title="s__('ContainerRegistry|Remove tag')"
:aria-label="s__('ContainerRegistry|Remove tag')"
data-container="body"
v-tooltip
@click="handleDeleteRegistry(item)">
<i
class="fa fa-trash"
aria-hidden="true">
</i>
</button>
</td>
</tr>
</tbody>
</table>
<table-pagination
v-if="shouldRenderPagination"
:change="onPageChange"
:page-info="repo.pagination"
/>
</div>
</template>
import { __ } from '../locale';
export const errorMessagesTypes = {
FETCH_REGISTRY: 'FETCH_REGISTRY',
FETCH_REPOS: 'FETCH_REPOS',
DELETE_REPO: 'DELETE_REPO',
DELETE_REGISTRY: 'DELETE_REGISTRY',
};
export const errorMessages = {
[errorMessagesTypes.FETCH_REGISTRY]: __('Something went wrong while fetching the registry list.'),
[errorMessagesTypes.FETCH_REPOS]: __('Something went wrong while fetching the projects.'),
[errorMessagesTypes.DELETE_REPO]: __('Something went wrong on our end.'),
[errorMessagesTypes.DELETE_REGISTRY]: __('Something went wrong on our end.'),
};
import Vue from 'vue';
import registryApp from './components/app.vue';
import Translate from '../vue_shared/translate';
Vue.use(Translate);
document.addEventListener('DOMContentLoaded', () => new Vue({
el: '#js-vue-registry-images',
components: {
registryApp,
},
data() {
const dataset = document.querySelector(this.$options.el).dataset;
return {
endpoint: dataset.endpoint,
};
},
render(createElement) {
return createElement('registry-app', {
props: {
endpoint: this.endpoint,
},
});
},
}));
import Vue from 'vue';
import VueResource from 'vue-resource';
import * as types from './mutation_types';
Vue.use(VueResource);
export const fetchRepos = ({ commit, state }) => {
commit(types.TOGGLE_MAIN_LOADING);
return Vue.http.get(state.endpoint)
.then(res => res.json())
.then((response) => {
commit(types.TOGGLE_MAIN_LOADING);
commit(types.SET_REPOS_LIST, response);
});
};
export const fetchList = ({ commit }, { repo, page }) => {
commit(types.TOGGLE_REGISTRY_LIST_LOADING, repo);
return Vue.http.get(repo.tagsPath, { params: { page } })
.then((response) => {
const headers = response.headers;
return response.json().then((resp) => {
commit(types.TOGGLE_REGISTRY_LIST_LOADING, repo);
commit(types.SET_REGISTRY_LIST, { repo, resp, headers });
});
});
};
export const deleteRepo = ({ commit }, repo) => Vue.http.delete(repo.destroyPath)
.then(res => res.json());
export const deleteRegistry = ({ commit }, image) => Vue.http.delete(image.destroyPath)
.then(res => res.json());
export const setMainEndpoint = ({ commit }, data) => commit(types.SET_MAIN_ENDPOINT, data);
export const toggleLoading = ({ commit }) => commit(types.TOGGLE_MAIN_LOADING);
export const isLoading = state => state.isLoading;
export const repos = state => state.repos;
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
Vue.use(Vuex);
export default new Vuex.Store({
state: {
isLoading: false,
endpoint: '', // initial endpoint to fetch the repos list
/**
* Each object in `repos` has the following strucure:
* {
* name: String,
* isLoading: Boolean,
* tagsPath: String // endpoint to request the list
* destroyPath: String // endpoit to delete the repo
* list: Array // List of the registry images
* }
*
* Each registry image inside `list` has the following structure:
* {
* tag: String,
* revision: String
* shortRevision: String
* size: Number
* layers: Number
* createdAt: String
* destroyPath: String // endpoit to delete each image
* }
*/
repos: [],
},
actions,
getters,
mutations,
});
export const SET_MAIN_ENDPOINT = 'SET_MAIN_ENDPOINT';
export const SET_REPOS_LIST = 'SET_REPOS_LIST';
export const TOGGLE_MAIN_LOADING = 'TOGGLE_MAIN_LOADING';
export const SET_REGISTRY_LIST = 'SET_REGISTRY_LIST';
export const TOGGLE_REGISTRY_LIST_LOADING = 'TOGGLE_REGISTRY_LIST_LOADING';
import * as types from './mutation_types';
import { parseIntPagination, normalizeHeaders } from '../../lib/utils/common_utils';
export default {
[types.SET_MAIN_ENDPOINT](state, endpoint) {
Object.assign(state, { endpoint });
},
[types.SET_REPOS_LIST](state, list) {
Object.assign(state, {
repos: list.map(el => ({
canDelete: !!el.destroy_path,
destroyPath: el.destroy_path,
id: el.id,
isLoading: false,
list: [],
location: el.location,
name: el.path,
tagsPath: el.tags_path,
})),
});
},
[types.TOGGLE_MAIN_LOADING](state) {
Object.assign(state, { isLoading: !state.isLoading });
},
[types.SET_REGISTRY_LIST](state, { repo, resp, headers }) {
const listToUpdate = state.repos.find(el => el.id === repo.id);
const normalizedHeaders = normalizeHeaders(headers);
const pagination = parseIntPagination(normalizedHeaders);
listToUpdate.pagination = pagination;
listToUpdate.list = resp.map(element => ({
tag: element.name,
revision: element.revision,
shortRevision: element.short_revision,
size: element.size,
layers: element.layers,
location: element.location,
createdAt: element.created_at,
destroyPath: element.destroy_path,
canDelete: !!element.destroy_path,
}));
},
[types.TOGGLE_REGISTRY_LIST_LOADING](state, list) {
const listToUpdate = state.repos.find(el => el.id === list.id);
listToUpdate.isLoading = !listToUpdate.isLoading;
},
};
<script>
/**
* Falls back to the code used in `copy_to_clipboard.js`
*/
export default {
name: 'clipboardButton',
props: {
text: {
type: String,
required: true,
},
title: {
type: String,
required: true,
},
},
};
</script>
<template>
<button
type="button"
class="btn btn-transparent btn-clipboard"
:data-title="title"
:data-clipboard-text="text">
<i
aria-hidden="true"
class="fa fa-clipboard">
</i>
</button>
</template>
...@@ -9,6 +9,14 @@ ...@@ -9,6 +9,14 @@
.container-image-head { .container-image-head {
padding: 0 16px; padding: 0 16px;
line-height: 4em; line-height: 4em;
.btn-link {
padding: 0;
&:focus {
outline: none;
}
}
} }
.table.tags { .table.tags {
......
...@@ -12,3 +12,7 @@ ...@@ -12,3 +12,7 @@
margin-left: 10px; margin-left: 10px;
} }
} }
.registry-placeholder {
min-height: 60px;
}
...@@ -6,17 +6,26 @@ module Projects ...@@ -6,17 +6,26 @@ module Projects
def index def index
@images = project.container_repositories @images = project.container_repositories
respond_to do |format|
format.html
format.json do
render json: ContainerRepositoriesSerializer
.new(project: project, current_user: current_user)
.represent(@images)
end
end
end end
def destroy def destroy
if image.destroy if image.destroy
redirect_to project_container_registry_index_path(@project), respond_to do |format|
status: 302, format.json { head :no_content }
notice: 'Image repository has been removed successfully!' end
else else
redirect_to project_container_registry_index_path(@project), respond_to do |format|
status: 302, format.json { head :bad_request }
alert: 'Failed to remove image repository!' end
end end
end end
......
...@@ -3,20 +3,35 @@ module Projects ...@@ -3,20 +3,35 @@ module Projects
class TagsController < ::Projects::Registry::ApplicationController class TagsController < ::Projects::Registry::ApplicationController
before_action :authorize_update_container_image!, only: [:destroy] before_action :authorize_update_container_image!, only: [:destroy]
def index
respond_to do |format|
format.json do
render json: ContainerTagsSerializer
.new(project: @project, current_user: @current_user)
.with_pagination(request, response)
.represent(tags)
end
end
end
def destroy def destroy
if tag.delete if tag.delete
redirect_to project_container_registry_index_path(@project), respond_to do |format|
status: 302, format.json { head :no_content }
notice: 'Registry tag has been removed successfully!' end
else else
redirect_to project_container_registry_index_path(@project), respond_to do |format|
status: 302, format.json { head :bad_request }
alert: 'Failed to remove registry tag!' end
end end
end end
private private
def tags
Kaminari::PaginatableArray.new(image.tags, limit: 15)
end
def image def image
@image ||= project.container_repositories @image ||= project.container_repositories
.find(params[:repository_id]) .find(params[:repository_id])
......
class ContainerRepositoriesSerializer < BaseSerializer
entity ContainerRepositoryEntity
end
class ContainerRepositoryEntity < Grape::Entity
include RequestAwareEntity
expose :id, :path, :location
expose :tags_path do |repository|
project_registry_repository_tags_path(project, repository, format: :json)
end
expose :destroy_path, if: -> (*) { can_destroy? } do |repository|
project_container_registry_path(project, repository, format: :json)
end
private
alias_method :repository, :object
def project
request.project
end
def can_destroy?
can?(request.current_user, :update_container_image, project)
end
end
class ContainerTagEntity < Grape::Entity
include RequestAwareEntity
expose :name, :location, :revision, :total_size, :created_at
expose :destroy_path, if: -> (*) { can_destroy? } do |tag|
project_registry_repository_tag_path(project, tag.repository, tag.name, format: :json)
end
private
alias_method :tag, :object
def project
request.project
end
def can_destroy?
# TODO: We check permission against @project, not tag,
# as tag is no AR object that is attached to project
can?(request.current_user, :update_container_image, project)
end
end
class ContainerTagsSerializer < BaseSerializer
entity ContainerTagEntity
def with_pagination(request, response)
tap { @paginator = Gitlab::Serializer::Pagination.new(request, response) }
end
def paginated?
@paginator.present?
end
def represent(resource, opts = {})
resource = @paginator.paginate(resource) if paginated?
super(resource, opts)
end
end
.container-image.js-toggle-container
.container-image-head
= link_to "#", class: "js-toggle-button" do
= icon('chevron-down', 'aria-hidden': 'true')
= escape_once(image.path)
= clipboard_button(clipboard_text: "docker pull #{image.location}")
- if can?(current_user, :update_container_image, @project)
.controls.hidden-xs.pull-right
= link_to project_container_registry_path(@project, image),
class: 'btn btn-remove has-tooltip',
title: 'Remove repository',
data: { confirm: 'Are you sure?' },
method: :delete do
= icon('trash cred', 'aria-hidden': 'true')
.container-image-tags.js-toggle-content.hide
- if image.has_tags?
.table-holder
%table.table.tags
%thead
%tr
%th Tag
%th Tag ID
%th Size
%th Created
- if can?(current_user, :update_container_image, @project)
%th
= render partial: 'tag', collection: image.tags
- else
.nothing-here-block No tags in Container Registry for this container image.
- page_title "Container Registry" - page_title "Container Registry"
.row.prepend-top-default.append-bottom-default %section
.col-lg-3 .settings-header
%h4.prepend-top-0 %h4
= page_title = page_title
%p %p
With the Docker Container Registry integrated into GitLab, every project = s_('ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images.')
can have its own space to store its Docker images.
%p.append-bottom-0 %p.append-bottom-0
= succeed '.' do = succeed '.' do
Learn more about = s_('ContainerRegistry|Learn more about')
= link_to 'Container Registry', help_page_path('user/project/container_registry'), target: '_blank' = link_to _('Container Registry'), help_page_path('user/project/container_registry'), target: '_blank'
.row.registry-placeholder.prepend-bottom-10
.col-lg-12
#js-vue-registry-images{ data: { endpoint: project_container_registry_index_path(@project, format: :json) } }
.col-lg-9 = page_specific_javascript_bundle_tag('common_vue')
= page_specific_javascript_bundle_tag('registry_list')
.row.prepend-top-10
.col-lg-12
.panel.panel-default .panel.panel-default
.panel-heading .panel-heading
%h4.panel-title %h4.panel-title
How to use the Container Registry = s_('ContainerRegistry|How to use the Container Registry')
.panel-body .panel-body
%p %p
First log in to GitLab&rsquo;s Container Registry using your GitLab username - link_token = link_to(_('personal access token'), help_page_path('user/profile/account/two_factor_authentication', anchor: 'personal-access-tokens'), target: '_blank')
and password. If you have - link_2fa = link_to(_('2FA enabled'), help_page_path('user/profile/account/two_factor_authentication'), target: '_blank')
= link_to '2FA enabled', help_page_path('user/profile/account/two_factor_authentication'), target: '_blank' = s_('ContainerRegistry|First log in to GitLab&rsquo;s Container Registry using your GitLab username and password. If you have %{link_2fa} you need to use a %{link_token}:').html_safe % { link_2fa: link_2fa, link_token: link_token }
you need to use a
= succeed ':' do
= link_to 'personal access token', help_page_path('user/profile/account/two_factor_authentication', anchor: 'personal-access-tokens'), target: '_blank'
%pre %pre
docker login #{Gitlab.config.registry.host_port} docker login #{Gitlab.config.registry.host_port}
%br %br
%p %p
Once you log in, you&rsquo;re free to create and upload a container image = s_('ContainerRegistry|Once you log in, you&rsquo;re free to create and upload a container image using the common %{build} and %{push} commands').html_safe % { build: "<code>build</code>".html_safe, push: "<code>push</code>".html_safe }
using the common
%code build
and
%code push
commands:
%pre %pre
:plain :plain
docker build -t #{escape_once(@project.container_registry_url)} . docker build -t #{escape_once(@project.container_registry_url)} .
docker push #{escape_once(@project.container_registry_url)} docker push #{escape_once(@project.container_registry_url)}
%hr %hr
%h5.prepend-top-default %h5.prepend-top-default
Use different image names = s_('ContainerRegistry|Use different image names')
%p.light %p.light
GitLab supports up to 3 levels of image names. The following = s_('ContainerRegistry|GitLab supports up to 3 levels of image names. The following examples of images are valid for your project:')
examples of images are valid for your project:
%pre %pre
:plain :plain
#{escape_once(@project.container_registry_url)}:tag #{escape_once(@project.container_registry_url)}:tag
#{escape_once(@project.container_registry_url)}/optional-image-name:tag #{escape_once(@project.container_registry_url)}/optional-image-name:tag
#{escape_once(@project.container_registry_url)}/optional-name/optional-image-name:tag #{escape_once(@project.container_registry_url)}/optional-name/optional-image-name:tag
- if @images.blank?
%p.settings-message.text-center.append-bottom-default
No container images stored for this project. Add one by following the
instructions above.
- else
= render partial: 'image', collection: @images
...@@ -271,7 +271,7 @@ constraints(ProjectUrlConstrainer.new) do ...@@ -271,7 +271,7 @@ constraints(ProjectUrlConstrainer.new) do
namespace :registry do namespace :registry do
resources :repository, only: [] do resources :repository, only: [] do
resources :tags, only: [:destroy], resources :tags, only: [:index, :destroy],
constraints: { id: Gitlab::Regex.container_registry_tag_regex } constraints: { id: Gitlab::Regex.container_registry_tag_regex }
end end
end end
......
...@@ -68,6 +68,7 @@ var config = { ...@@ -68,6 +68,7 @@ var config = {
prometheus_metrics: './prometheus_metrics', prometheus_metrics: './prometheus_metrics',
protected_branches: './protected_branches', protected_branches: './protected_branches',
protected_tags: './protected_tags', protected_tags: './protected_tags',
registry_list: './registry/index.js',
repo: './repo/index.js', repo: './repo/index.js',
sidebar: './sidebar/sidebar_bundle.js', sidebar: './sidebar/sidebar_bundle.js',
schedule_form: './pipeline_schedules/pipeline_schedule_form_bundle.js', schedule_form: './pipeline_schedules/pipeline_schedule_form_bundle.js',
...@@ -200,6 +201,7 @@ var config = { ...@@ -200,6 +201,7 @@ var config = {
'pdf_viewer', 'pdf_viewer',
'pipelines', 'pipelines',
'pipelines_details', 'pipelines_details',
'registry_list',
'repo', 'repo',
'schedule_form', 'schedule_form',
'schedules_index', 'schedules_index',
......
...@@ -42,6 +42,13 @@ describe Projects::Registry::RepositoriesController do ...@@ -42,6 +42,13 @@ describe Projects::Registry::RepositoriesController do
expect { go_to_index }.to change { ContainerRepository.all.count }.by(1) expect { go_to_index }.to change { ContainerRepository.all.count }.by(1)
expect(ContainerRepository.first).to be_root_repository expect(ContainerRepository.first).to be_root_repository
end end
it 'json has a list of projects' do
go_to_index(format: :json)
expect(response).to have_http_status(:ok)
expect(response).to match_response_schema('registry/repositories')
end
end end
context 'when there are no tags for this repository' do context 'when there are no tags for this repository' do
...@@ -58,6 +65,31 @@ describe Projects::Registry::RepositoriesController do ...@@ -58,6 +65,31 @@ describe Projects::Registry::RepositoriesController do
it 'does not ensure root container repository' do it 'does not ensure root container repository' do
expect { go_to_index }.not_to change { ContainerRepository.all.count } expect { go_to_index }.not_to change { ContainerRepository.all.count }
end end
it 'responds with json if asked' do
go_to_index(format: :json)
expect(response).to have_http_status(:ok)
expect(json_response).to be_kind_of(Array)
end
end
end
end
describe 'DELETE destroy' do
context 'when root container repository exists' do
let!(:repository) do
create(:container_repository, :root, project: project)
end
before do
stub_container_registry_tags(repository: :any, tags: [])
end
it 'deletes a repository' do
expect { delete_repository(repository) }.to change { ContainerRepository.all.count }.by(-1)
expect(response).to have_http_status(:no_content)
end end
end end
end end
...@@ -77,8 +109,16 @@ describe Projects::Registry::RepositoriesController do ...@@ -77,8 +109,16 @@ describe Projects::Registry::RepositoriesController do
end end
end end
def go_to_index def go_to_index(format: :html)
get :index, namespace_id: project.namespace, get :index, namespace_id: project.namespace,
project_id: project project_id: project,
format: format
end
def delete_repository(repository)
delete :destroy, namespace_id: project.namespace,
project_id: project,
id: repository,
format: :json
end end
end end
...@@ -4,24 +4,83 @@ describe Projects::Registry::TagsController do ...@@ -4,24 +4,83 @@ describe Projects::Registry::TagsController do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:project) { create(:project, :private) } let(:project) { create(:project, :private) }
let(:repository) do
create(:container_repository, name: 'image', project: project)
end
before do before do
sign_in(user) sign_in(user)
stub_container_registry_config(enabled: true) stub_container_registry_config(enabled: true)
end end
context 'when user has access to registry' do describe 'GET index' do
let(:tags) do
Array.new(40) { |i| "tag#{i}" }
end
before do
stub_container_registry_tags(repository: /image/, tags: tags)
end
context 'when user can control the registry' do
before do before do
project.add_developer(user) project.add_developer(user)
end end
it 'receive a list of tags' do
get_tags
expect(response).to have_http_status(:ok)
expect(response).to match_response_schema('registry/tags')
expect(response).to include_pagination_headers
end
end
context 'when user can read the registry' do
before do
project.add_reporter(user)
end
it 'receive a list of tags' do
get_tags
expect(response).to have_http_status(:ok)
expect(response).to match_response_schema('registry/tags')
expect(response).to include_pagination_headers
end
end
context 'when user does not have access to registry' do
before do
project.add_guest(user)
end
it 'does not receive a list of tags' do
get_tags
expect(response).to have_http_status(:not_found)
end
end
private
def get_tags
get :index, namespace_id: project.namespace,
project_id: project,
repository_id: repository,
format: :json
end
end
describe 'POST destroy' do describe 'POST destroy' do
context 'when there is matching tag present' do context 'when user has access to registry' do
before do before do
stub_container_registry_tags(repository: /image/, tags: %w[rc1 test.]) project.add_developer(user)
end end
let(:repository) do context 'when there is matching tag present' do
create(:container_repository, name: 'image', project: project) before do
stub_container_registry_tags(repository: repository.path, tags: %w[rc1 test.])
end end
it 'makes it possible to delete regular tag' do it 'makes it possible to delete regular tag' do
...@@ -37,12 +96,15 @@ describe Projects::Registry::TagsController do ...@@ -37,12 +96,15 @@ describe Projects::Registry::TagsController do
end end
end end
end end
end
private
def destroy_tag(name) def destroy_tag(name)
post :destroy, namespace_id: project.namespace, post :destroy, namespace_id: project.namespace,
project_id: project, project_id: project,
repository_id: repository, repository_id: repository,
id: name id: name,
format: :json
end
end end
end end
require 'spec_helper' require 'spec_helper'
describe "Container Registry" do describe "Container Registry", js: true do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:project) { create(:project) } let(:project) { create(:project) }
...@@ -41,16 +41,19 @@ describe "Container Registry" do ...@@ -41,16 +41,19 @@ describe "Container Registry" do
expect_any_instance_of(ContainerRepository) expect_any_instance_of(ContainerRepository)
.to receive(:delete_tags!).and_return(true) .to receive(:delete_tags!).and_return(true)
click_on 'Remove repository' click_on(class: 'js-remove-repo')
end end
scenario 'user removes a specific tag from container repository' do scenario 'user removes a specific tag from container repository' do
visit_container_registry visit_container_registry
find('.js-toggle-repo').trigger('click')
wait_for_requests
expect_any_instance_of(ContainerRegistry::Tag) expect_any_instance_of(ContainerRegistry::Tag)
.to receive(:delete).and_return(true) .to receive(:delete).and_return(true)
click_on 'Remove tag' click_on(class: 'js-delete-registry')
end end
end end
......
{
"type": "array",
"items": {
"$ref": "repository.json"
}
}
{
"type": "object",
"required" : [
"id",
"path",
"location",
"tags_path"
],
"properties" : {
"id": {
"type": "integer"
},
"path": {
"type": "string"
},
"location": {
"type": "string"
},
"tags_path": {
"type": "string"
},
"destroy_path": {
"type": "string"
}
},
"additionalProperties": false
}
{
"type": "object",
"required" : [
"name",
"location"
],
"properties" : {
"name": {
"type": "string"
},
"location": {
"type": "string"
},
"revision": {
"type": "string"
},
"total_size": {
"type": "integer"
},
"created_at": {
"type": "date"
},
"destroy_path": {
"type": "string"
}
},
"additionalProperties": false
}
{
"type": "array",
"items": {
"$ref": "tag.json"
}
}
import * as actions from '~/notes/stores/actions'; import * as actions from '~/notes/stores/actions';
import testAction from './helpers'; import testAction from '../../helpers/vuex_action_helper';
import { discussionMock, notesDataMock, userDataMock, issueDataMock, individualNote } from '../mock_data'; import { discussionMock, notesDataMock, userDataMock, issueDataMock, individualNote } from '../mock_data';
describe('Actions Notes Store', () => { describe('Actions Notes Store', () => {
......
import Vue from 'vue';
import registry from '~/registry/components/app.vue';
import mountComponent from '../../helpers/vue_mount_component_helper';
import { reposServerResponse } from '../mock_data';
describe('Registry List', () => {
let vm;
let Component;
beforeEach(() => {
Component = Vue.extend(registry);
});
afterEach(() => {
vm.$destroy();
});
describe('with data', () => {
const interceptor = (request, next) => {
next(request.respondWith(JSON.stringify(reposServerResponse), {
status: 200,
}));
};
beforeEach(() => {
Vue.http.interceptors.push(interceptor);
vm = mountComponent(Component, { endpoint: 'foo' });
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
});
it('should render a list of repos', (done) => {
setTimeout(() => {
expect(vm.$store.state.repos.length).toEqual(reposServerResponse.length);
Vue.nextTick(() => {
expect(
vm.$el.querySelectorAll('.container-image').length,
).toEqual(reposServerResponse.length);
done();
});
}, 0);
});
describe('delete repository', () => {
it('should be possible to delete a repo', (done) => {
setTimeout(() => {
Vue.nextTick(() => {
expect(vm.$el.querySelector('.container-image-head .js-remove-repo')).toBeDefined();
done();
});
}, 0);
});
});
describe('toggle repository', () => {
it('should open the container', (done) => {
setTimeout(() => {
Vue.nextTick(() => {
vm.$el.querySelector('.js-toggle-repo').click();
Vue.nextTick(() => {
expect(vm.$el.querySelector('.js-toggle-repo i').className).toEqual('fa fa-chevron-up');
done();
});
});
}, 0);
});
});
});
describe('without data', () => {
const interceptor = (request, next) => {
next(request.respondWith(JSON.stringify([]), {
status: 200,
}));
};
beforeEach(() => {
Vue.http.interceptors.push(interceptor);
vm = mountComponent(Component, { endpoint: 'foo' });
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
});
it('should render empty message', (done) => {
setTimeout(() => {
expect(
vm.$el.querySelector('p').textContent.trim(),
).toEqual('No container images stored for this project. Add one by following the instructions above.');
done();
}, 0);
});
});
describe('while loading data', () => {
const interceptor = (request, next) => {
next(request.respondWith(JSON.stringify(reposServerResponse), {
status: 200,
}));
};
beforeEach(() => {
Vue.http.interceptors.push(interceptor);
vm = mountComponent(Component, { endpoint: 'foo' });
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
});
it('should render a loading spinner', (done) => {
Vue.nextTick(() => {
expect(vm.$el.querySelector('.fa-spinner')).not.toBe(null);
done();
});
});
});
});
import Vue from 'vue';
import collapsibleComponent from '~/registry/components/collapsible_container.vue';
import store from '~/registry/stores';
import { repoPropsData } from '../mock_data';
describe('collapsible registry container', () => {
let vm;
let Component;
beforeEach(() => {
Component = Vue.extend(collapsibleComponent);
vm = new Component({
store,
propsData: {
repo: repoPropsData,
},
}).$mount();
});
afterEach(() => {
vm.$destroy();
});
describe('toggle', () => {
it('should be closed by default', () => {
expect(vm.$el.querySelector('.container-image-tags')).toBe(null);
expect(vm.$el.querySelector('.container-image-head i').className).toEqual('fa fa-chevron-right');
});
it('should be open when user clicks on closed repo', (done) => {
vm.$el.querySelector('.js-toggle-repo').click();
Vue.nextTick(() => {
expect(vm.$el.querySelector('.container-image-tags')).toBeDefined();
expect(vm.$el.querySelector('.container-image-head i').className).toEqual('fa fa-chevron-up');
done();
});
});
it('should be closed when the user clicks on an opened repo', (done) => {
vm.$el.querySelector('.js-toggle-repo').click();
Vue.nextTick(() => {
vm.$el.querySelector('.js-toggle-repo').click();
Vue.nextTick(() => {
expect(vm.$el.querySelector('.container-image-tags')).toBe(null);
expect(vm.$el.querySelector('.container-image-head i').className).toEqual('fa fa-chevron-right');
done();
});
});
});
});
describe('delete repo', () => {
it('should be possible to delete a repo', () => {
expect(vm.$el.querySelector('.js-remove-repo')).toBeDefined();
});
});
});
import Vue from 'vue';
import tableRegistry from '~/registry/components/table_registry.vue';
import store from '~/registry/stores';
import { repoPropsData } from '../mock_data';
describe('table registry', () => {
let vm;
let Component;
beforeEach(() => {
Component = Vue.extend(tableRegistry);
vm = new Component({
store,
propsData: {
repo: repoPropsData,
},
}).$mount();
});
afterEach(() => {
vm.$destroy();
});
it('should render a table with the registry list', () => {
expect(
vm.$el.querySelectorAll('table tbody tr').length,
).toEqual(repoPropsData.list.length);
});
it('should render registry tag', () => {
const textRendered = vm.$el.querySelector('.table tbody tr').textContent.trim().replace(/\s\s+/g, ' ');
expect(textRendered).toContain(repoPropsData.list[0].tag);
expect(textRendered).toContain(repoPropsData.list[0].shortRevision);
expect(textRendered).toContain(repoPropsData.list[0].layers);
expect(textRendered).toContain(repoPropsData.list[0].size);
});
it('should be possible to delete a registry', () => {
expect(
vm.$el.querySelector('.table tbody tr .js-delete-registry'),
).toBeDefined();
});
describe('pagination', () => {
it('should be possible to change the page', () => {
expect(vm.$el.querySelector('.gl-pagination')).toBeDefined();
});
});
});
import * as getters from '~/registry/stores/getters';
describe('Getters Registry Store', () => {
let state;
beforeEach(() => {
state = {
isLoading: false,
endpoint: '/root/empty-project/container_registry.json',
repos: [{
canDelete: true,
destroyPath: 'bar',
id: '134',
isLoading: false,
list: [],
location: 'foo',
name: 'gitlab-org/omnibus-gitlab/foo',
tagsPath: 'foo',
}, {
canDelete: true,
destroyPath: 'bar',
id: '123',
isLoading: false,
list: [],
location: 'foo',
name: 'gitlab-org/omnibus-gitlab',
tagsPath: 'foo',
}],
};
});
describe('isLoading', () => {
it('should return the isLoading property', () => {
expect(getters.isLoading(state)).toEqual(state.isLoading);
});
});
describe('repos', () => {
it('should return the repos', () => {
expect(getters.repos(state)).toEqual(state.repos);
});
});
});
export const defaultState = {
isLoading: false,
endpoint: '',
repos: [],
};
export const reposServerResponse = [
{
destroy_path: 'path',
id: '123',
location: 'location',
path: 'foo',
tags_path: 'tags_path',
},
{
destroy_path: 'path_',
id: '456',
location: 'location_',
path: 'bar',
tags_path: 'tags_path_',
},
];
export const registryServerResponse = [
{
name: 'centos7',
short_revision: 'b118ab5b0',
revision: 'b118ab5b0e90b7cb5127db31d5321ac14961d097516a8e0e72084b6cdc783b43',
size: 679,
layers: 19,
location: 'location',
created_at: 1505828744434,
destroy_path: 'path_',
},
{
name: 'centos6',
short_revision: 'b118ab5b0',
revision: 'b118ab5b0e90b7cb5127db31d5321ac14961d097516a8e0e72084b6cdc783b43',
size: 679,
layers: 19,
location: 'location',
created_at: 1505828744434,
}];
export const parsedReposServerResponse = [
{
canDelete: true,
destroyPath: reposServerResponse[0].destroy_path,
id: reposServerResponse[0].id,
isLoading: false,
list: [],
location: reposServerResponse[0].location,
name: reposServerResponse[0].path,
tagsPath: reposServerResponse[0].tags_path,
},
{
canDelete: true,
destroyPath: reposServerResponse[1].destroy_path,
id: reposServerResponse[1].id,
isLoading: false,
list: [],
location: reposServerResponse[1].location,
name: reposServerResponse[1].path,
tagsPath: reposServerResponse[1].tags_path,
},
];
export const parsedRegistryServerResponse = [
{
tag: registryServerResponse[0].name,
revision: registryServerResponse[0].revision,
shortRevision: registryServerResponse[0].short_revision,
size: registryServerResponse[0].size,
layers: registryServerResponse[0].layers,
location: registryServerResponse[0].location,
createdAt: registryServerResponse[0].created_at,
destroyPath: registryServerResponse[0].destroy_path,
canDelete: true,
},
{
tag: registryServerResponse[1].name,
revision: registryServerResponse[1].revision,
shortRevision: registryServerResponse[1].short_revision,
size: registryServerResponse[1].size,
layers: registryServerResponse[1].layers,
location: registryServerResponse[1].location,
createdAt: registryServerResponse[1].created_at,
destroyPath: registryServerResponse[1].destroy_path,
canDelete: false,
},
];
export const repoPropsData = {
canDelete: true,
destroyPath: 'path',
id: '123',
isLoading: false,
list: [
{
tag: 'centos6',
revision: 'b118ab5b0e90b7cb5127db31d5321ac14961d097516a8e0e72084b6cdc783b43',
shortRevision: 'b118ab5b0',
size: 19,
layers: 10,
location: 'location',
createdAt: 1505828744434,
destroyPath: 'path',
canDelete: true,
},
],
location: 'location',
name: 'foo',
tagsPath: 'path',
pagination: {
perPage: 5,
page: 1,
total: 13,
totalPages: 1,
nextPage: null,
previousPage: null,
},
};
import Vue from 'vue';
import VueResource from 'vue-resource';
import _ from 'underscore';
import * as actions from '~/registry/stores/actions';
import * as types from '~/registry/stores/mutation_types';
import testAction from '../../helpers/vuex_action_helper';
import {
defaultState,
reposServerResponse,
registryServerResponse,
parsedReposServerResponse,
} from '../mock_data';
Vue.use(VueResource);
describe('Actions Registry Store', () => {
let interceptor;
let mockedState;
beforeEach(() => {
mockedState = defaultState;
});
describe('server requests', () => {
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
});
describe('fetchRepos', () => {
beforeEach(() => {
interceptor = (request, next) => {
next(request.respondWith(JSON.stringify(reposServerResponse), {
status: 200,
}));
};
Vue.http.interceptors.push(interceptor);
});
it('should set receveived repos', (done) => {
testAction(actions.fetchRepos, null, mockedState, [
{ type: types.TOGGLE_MAIN_LOADING },
{ type: types.SET_REPOS_LIST, payload: reposServerResponse },
], done);
});
});
describe('fetchList', () => {
beforeEach(() => {
interceptor = (request, next) => {
next(request.respondWith(JSON.stringify(registryServerResponse), {
status: 200,
}));
};
Vue.http.interceptors.push(interceptor);
});
it('should set received list', (done) => {
mockedState.repos = parsedReposServerResponse;
testAction(actions.fetchList, { repo: mockedState.repos[1] }, mockedState, [
{ type: types.TOGGLE_REGISTRY_LIST_LOADING },
{ type: types.SET_REGISTRY_LIST, payload: registryServerResponse },
], done);
});
});
});
describe('setMainEndpoint', () => {
it('should commit set main endpoint', (done) => {
testAction(actions.setMainEndpoint, 'endpoint', mockedState, [
{ type: types.SET_MAIN_ENDPOINT, payload: 'endpoint' },
], done);
});
});
describe('toggleLoading', () => {
it('should commit toggle main loading', (done) => {
testAction(actions.toggleLoading, null, mockedState, [
{ type: types.TOGGLE_MAIN_LOADING },
], done);
});
});
});
import mutations from '~/registry/stores/mutations';
import * as types from '~/registry/stores/mutation_types';
import {
defaultState,
reposServerResponse,
registryServerResponse,
parsedReposServerResponse,
parsedRegistryServerResponse,
} from '../mock_data';
describe('Mutations Registry Store', () => {
let mockState;
beforeEach(() => {
mockState = defaultState;
});
describe('SET_MAIN_ENDPOINT', () => {
it('should set the main endpoint', () => {
const expectedState = Object.assign({}, mockState, { endpoint: 'foo' });
mutations[types.SET_MAIN_ENDPOINT](mockState, 'foo');
expect(mockState).toEqual(expectedState);
});
});
describe('SET_REPOS_LIST', () => {
it('should set a parsed repository list', () => {
mutations[types.SET_REPOS_LIST](mockState, reposServerResponse);
expect(mockState.repos).toEqual(parsedReposServerResponse);
});
});
describe('TOGGLE_MAIN_LOADING', () => {
it('should set a parsed repository list', () => {
mutations[types.TOGGLE_MAIN_LOADING](mockState);
expect(mockState.isLoading).toEqual(true);
});
});
describe('SET_REGISTRY_LIST', () => {
it('should set a list of registries in a specific repository', () => {
mutations[types.SET_REPOS_LIST](mockState, reposServerResponse);
mutations[types.SET_REGISTRY_LIST](mockState, {
repo: mockState.repos[0],
resp: registryServerResponse,
headers: {
'x-per-page': 2,
'x-page': 1,
'x-total': 10,
},
});
expect(mockState.repos[0].list).toEqual(parsedRegistryServerResponse);
expect(mockState.repos[0].pagination).toEqual({
perPage: 2,
page: 1,
total: 10,
totalPages: NaN,
nextPage: NaN,
previousPage: NaN,
});
});
});
describe('TOGGLE_REGISTRY_LIST_LOADING', () => {
it('should toggle isLoading property for a specific repository', () => {
mutations[types.SET_REPOS_LIST](mockState, reposServerResponse);
mutations[types.SET_REGISTRY_LIST](mockState, {
repo: mockState.repos[0],
resp: registryServerResponse,
headers: {
'x-per-page': 2,
'x-page': 1,
'x-total': 10,
},
});
mutations[types.TOGGLE_REGISTRY_LIST_LOADING](mockState, mockState.repos[0]);
expect(mockState.repos[0].isLoading).toEqual(true);
});
});
});
require 'spec_helper'
describe ContainerRepositoryEntity do
let(:entity) do
described_class.new(repository, request: request)
end
set(:project) { create(:project) }
set(:user) { create(:user) }
set(:repository) { create(:container_repository, project: project) }
let(:request) { double('request') }
subject { entity.as_json }
before do
stub_container_registry_config(enabled: true)
allow(request).to receive(:project).and_return(project)
allow(request).to receive(:current_user).and_return(user)
end
it 'exposes required informations' do
expect(subject).to include(:id, :path, :location, :tags_path)
end
context 'when user can manage repositories' do
before do
project.add_developer(user)
end
it 'exposes destroy_path' do
expect(subject).to include(:destroy_path)
end
end
context 'when user cannot manage repositories' do
it 'does not expose destroy_path' do
expect(subject).not_to include(:destroy_path)
end
end
end
require 'spec_helper'
describe ContainerTagEntity do
let(:entity) do
described_class.new(tag, request: request)
end
set(:project) { create(:project) }
set(:user) { create(:user) }
set(:repository) { create(:container_repository, name: 'image', project: project) }
let(:request) { double('request') }
let(:tag) { repository.tag('test') }
subject { entity.as_json }
before do
stub_container_registry_config(enabled: true)
stub_container_registry_tags(repository: /image/, tags: %w[test])
allow(request).to receive(:project).and_return(project)
allow(request).to receive(:current_user).and_return(user)
end
it 'exposes required informations' do
expect(subject).to include(:name, :location, :revision, :total_size, :created_at)
end
context 'when user can manage repositories' do
before do
project.add_developer(user)
end
it 'exposes destroy_path' do
expect(subject).to include(:destroy_path)
end
end
context 'when user cannot manage repositories' do
it 'does not expose destroy_path' do
expect(subject).not_to include(:destroy_path)
end
end
end
...@@ -39,11 +39,11 @@ module StubGitlabCalls ...@@ -39,11 +39,11 @@ module StubGitlabCalls
.and_return({ 'tags' => tags }) .and_return({ 'tags' => tags })
allow_any_instance_of(ContainerRegistry::Client) allow_any_instance_of(ContainerRegistry::Client)
.to receive(:repository_manifest).with(repository) .to receive(:repository_manifest).with(repository, anything)
.and_return(stub_container_registry_tag_manifest) .and_return(stub_container_registry_tag_manifest)
allow_any_instance_of(ContainerRegistry::Client) allow_any_instance_of(ContainerRegistry::Client)
.to receive(:blob).with(repository) .to receive(:blob).with(repository, anything, 'application/octet-stream')
.and_return(stub_container_registry_blob) .and_return(stub_container_registry_blob)
end end
......
require 'spec_helper'
describe 'projects/registry/repositories/index' do
let(:group) { create(:group, path: 'group') }
let(:project) { create(:project, group: group, path: 'test') }
let(:repository) do
create(:container_repository, project: project, name: 'image')
end
before do
stub_container_registry_config(enabled: true,
host_port: 'registry.gitlab',
api_url: 'http://registry.gitlab')
stub_container_registry_tags(repository: :any, tags: [:latest])
assign(:project, project)
assign(:images, [repository])
allow(view).to receive(:can?).and_return(true)
end
it 'contains container repository path' do
render
expect(rendered).to have_content 'group/test/image'
end
it 'contains attribute for copying tag location into clipboard' do
render
expect(rendered).to have_css 'button[data-clipboard-text="docker pull ' \
'registry.gitlab/group/test/image:latest"]'
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