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')
.panel.panel-default = page_specific_javascript_bundle_tag('registry_list')
.panel-heading
%h4.panel-title
How to use the Container Registry
.panel-body
%p
First log in to GitLab&rsquo;s Container Registry using your GitLab username
and password. If you have
= link_to '2FA enabled', help_page_path('user/profile/account/two_factor_authentication'), target: '_blank'
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
docker login #{Gitlab.config.registry.host_port}
%br
%p
Once you log in, you&rsquo;re free to create and upload a container image
using the common
%code build
and
%code push
commands:
%pre
:plain
docker build -t #{escape_once(@project.container_registry_url)} .
docker push #{escape_once(@project.container_registry_url)}
%hr .row.prepend-top-10
%h5.prepend-top-default .col-lg-12
Use different image names .panel.panel-default
%p.light .panel-heading
GitLab supports up to 3 levels of image names. The following %h4.panel-title
examples of images are valid for your project: = s_('ContainerRegistry|How to use the Container Registry')
%pre .panel-body
:plain %p
#{escape_once(@project.container_registry_url)}:tag - link_token = link_to(_('personal access token'), help_page_path('user/profile/account/two_factor_authentication', anchor: 'personal-access-tokens'), target: '_blank')
#{escape_once(@project.container_registry_url)}/optional-image-name:tag - link_2fa = link_to(_('2FA enabled'), help_page_path('user/profile/account/two_factor_authentication'), target: '_blank')
#{escape_once(@project.container_registry_url)}/optional-name/optional-image-name:tag = 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 }
%pre
- if @images.blank? docker login #{Gitlab.config.registry.host_port}
%p.settings-message.text-center.append-bottom-default %br
No container images stored for this project. Add one by following the %p
instructions above. = 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 }
- else %pre
= render partial: 'image', collection: @images :plain
docker build -t #{escape_once(@project.container_registry_url)} .
docker push #{escape_once(@project.container_registry_url)}
%hr
%h5.prepend-top-default
= s_('ContainerRegistry|Use different image names')
%p.light
= s_('ContainerRegistry|GitLab supports up to 3 levels of image names. The following examples of images are valid for your project:')
%pre
:plain
#{escape_once(@project.container_registry_url)}:tag
#{escape_once(@project.container_registry_url)}/optional-image-name:tag
#{escape_once(@project.container_registry_url)}/optional-name/optional-image-name:tag
...@@ -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 before do
project.add_developer(user) stub_container_registry_tags(repository: /image/, tags: tags)
end end
describe 'POST destroy' do context 'when user can control the registry' do
before do
project.add_developer(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 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
context 'when user has access to registry' do
before do
project.add_developer(user)
end
context 'when there is matching tag present' do context 'when there is matching tag present' do
before do before do
stub_container_registry_tags(repository: /image/, tags: %w[rc1 test.]) stub_container_registry_tags(repository: repository.path, tags: %w[rc1 test.])
end
let(:repository) do
create(:container_repository, name: 'image', project: project)
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
def destroy_tag(name) private
post :destroy, namespace_id: project.namespace,
project_id: project, def destroy_tag(name)
repository_id: repository, post :destroy, namespace_id: project.namespace,
id: name project_id: project,
repository_id: repository,
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