Commit cc2f3c4d authored by Nicolò Maria Mezzopera's avatar Nicolò Maria Mezzopera Committed by Filipa Lacerda

Add group level container registry

- ee and ce sidebar
- view
- js bootstrapping

Finalise BE and FE features

- one for project related info
- one for group related info
- add handling of groupPage prop

Add groupPage prop and refactor render fn

Generate API path to fill endpoint prop

Add disableDelete and tagsPath options to store

- disableDelete added to state,
- disableDelete mutation, mutation type and action
- modified fetchList to calculate tagsPath if not present

Resolve linting errors on routes and controller

- config/routes/groups.rb
- controller/groups/container_registries_controller.rb

Remove hardcoded path from BE and calc it on FE

Remove wrong if in navbar haml file

Refactor disableDelete to isDeleteDisabled

- mutations
- mutation types
- getters
- action
- state
- components methods

Disable menu and page if feature is disabled

- Model addition to check if feature is enabled
- helper function
- check in controller
- disable menu partial if not enabled
- add title to the page for breadcrumb
- fix side menu open state

Use isDeleteDisabled in components

- remove image delete button
- remove tag delete button
- remove tags delete button

Rename container_registry_feature_available

- to container_registry_configured

Backend unit test for new feature

- model test
- helper test
- controller test

Force config setting to true before test body

Finalise frontend feature

- new updated text for empty state on group
- unit tests

Update translations strings

Add changelog entry

Lint ruby file

Apply suggestion to
spec/frontend/registry/components/app_spec.js
Apply suggestion to
spec/frontend/registry/components/app_spec.js
Add new line at the end of new haml files

Move container_registry_configured to controller

- from model to application_controller
- adjusted tests

Remove container_registry_configured from groups

- add check in container-registries-controller
- add check in groups_helper
- relative tests

Apply suggestion to s
pec/frontend/registry/components/group_empty_state_spec.js
Apply suggestion to
spec/frontend/registry/components/project_empty_state_spec.js
create dedicated computed for isGroupPage

Add feature test for package sidebar

Update snapshots along with updated test names

Address missing i18n methods

- feature test
- index for the new page

Implement maintainer feedback

Apply suggestion to
.../registry/components/project_empty_state.vue
Apply suggestion to
../registry/components/project_empty_state.vue
Apply suggestion to
.../registry/components/project_empty_state.vue
Adjust frontend tests as per review feedback

Refactor repo / global delete swtich

Add missing title prop to links

move container registry api to api.js

- add path and fn to api.js
- unit tests for api
- rewrite store

Refactor getList to use Api.js

- add projectContainerRegistryTagsPath to api
- unit test for api
- refactor the store

Apply suggestion to config/routes/group.rb
Remove redundant slash from api.js

Use repo name to calculate api path

- store name in namespace property
- path calculation
- comment for future notice

Remove slash to test mock path

Refactor container registry controller

- mirror project structure
- adjust test
- mirror structure for JS files
- set router

Correct check for gitlab foss

Implement review feedback

- add check if user is allowed to see registry
- tests
- change to module / class syntax

check if user can see registry in helper

- helper function
- tests

Implement review feedback

- add missing required: false
- move row delete check to method
- rename propsData to registryData

Add sign_in to groups pacakge sidebar feature spec

Fix url parsing for repository without namespace

Adjust changes from 'remove to clipboard'

- removed wrong template
- removed to clipboard from template strings

Update snapshots to match new copy

- project empty state

Reset api and api_spec to master

Return group container registry from controller

Refactor frontend to use controller data

Pass page param to axios call

Cleaner repositories_controller json function

Update tests to check json generation

Add  json content testing on controller

- test returned json with mock data

Address FE maintainer review

- explicit prop binding
- test cleanup
- missing vuex tests
- missing required props

Add search for proj in object in Repository entity

- source file
- tests updated

Align tests to review feedback

Linted groups_helper_spec

Add project preload in container_repository model

Add with_api_entity_associations to controller

Apply suggestion to
app/controllers/groups/registry/repositories_controller.rb
Apply suggestion to
app/controllers/groups/registry/repositories_controller.rb
Add new line after spec block for consistency

Adjust mutation test to punctual state check

- isDeleteDisabled
- endpoint

Adjust tests after master conflict resolution
parent 16d1d0aa
import initRegistryImages from '~/registry';
document.addEventListener('DOMContentLoaded', initRegistryImages);
......@@ -2,17 +2,19 @@
import { mapGetters, mapActions } from 'vuex';
import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui';
import store from '../stores';
import clipboardButton from '../../vue_shared/components/clipboard_button.vue';
import CollapsibleContainer from './collapsible_container.vue';
import ProjectEmptyState from './project_empty_state.vue';
import GroupEmptyState from './group_empty_state.vue';
import { s__, sprintf } from '../../locale';
export default {
name: 'RegistryListApp',
components: {
clipboardButton,
CollapsibleContainer,
GlEmptyState,
GlLoadingIcon,
ProjectEmptyState,
GroupEmptyState,
},
props: {
characterError: {
......@@ -38,19 +40,27 @@ export default {
},
personalAccessTokensHelpLink: {
type: String,
required: true,
required: false,
default: null,
},
registryHostUrlWithPort: {
type: String,
required: true,
required: false,
default: null,
},
repositoryUrl: {
type: String,
required: true,
},
isGroupPage: {
type: Boolean,
default: false,
required: false,
},
twoFactorAuthHelpLink: {
type: String,
required: true,
required: false,
default: null,
},
},
store,
......@@ -91,37 +101,10 @@ export default {
false,
);
},
notLoggedInToRegistryText() {
return sprintf(
s__(`ContainerRegistry|If you are not already logged in, you need to authenticate to
the Container Registry by using your GitLab username and password. If you have
%{twofaDocLinkStart}Two-Factor Authentication%{twofaDocLinkEnd} enabled, use a
%{personalAccessTokensDocLinkStart}Personal Access Token
%{personalAccessTokensDocLinkEnd}instead of a password.`),
{
twofaDocLinkStart: `<a href="${this.twoFactorAuthHelpLink}" target="_blank">`,
twofaDocLinkEnd: '</a>',
personalAccessTokensDocLinkStart: `<a href="${this.personalAccessTokensHelpLink}" target="_blank">`,
personalAccessTokensDocLinkEnd: '</a>',
},
false,
);
},
dockerLoginCommand() {
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
return `docker login ${this.registryHostUrlWithPort}`;
},
dockerBuildCommand() {
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
return `docker build -t ${this.repositoryUrl} .`;
},
dockerPushCommand() {
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
return `docker push ${this.repositoryUrl}`;
},
},
created() {
this.setMainEndpoint(this.endpoint);
this.setIsDeleteDisabled(this.isGroupPage);
},
mounted() {
if (!this.characterError) {
......@@ -129,7 +112,7 @@ export default {
}
},
methods: {
...mapActions(['setMainEndpoint', 'fetchRepos']),
...mapActions(['setMainEndpoint', 'fetchRepos', 'setIsDeleteDisabled']),
},
};
</script>
......@@ -152,57 +135,19 @@ export default {
<p v-html="introText"></p>
<collapsible-container v-for="item in repos" :key="item.id" :repo="item" />
</div>
<gl-empty-state
v-else
:title="s__('ContainerRegistry|There are no container images stored for this project')"
:svg-path="noContainersImage"
class="container-message"
>
<template #description>
<p class="js-no-container-images-text" v-html="noContainerImagesText"></p>
<h5>{{ s__('ContainerRegistry|Quick Start') }}</h5>
<p class="js-not-logged-in-to-registry-text" v-html="notLoggedInToRegistryText"></p>
<div class="input-group append-bottom-10">
<input :value="dockerLoginCommand" type="text" class="form-control monospace" readonly />
<span class="input-group-append">
<clipboard-button
:text="dockerLoginCommand"
:title="s__('ContainerRegistry|Copy login command')"
class="input-group-text"
/>
</span>
</div>
<p>
{{
s__(
'ContainerRegistry|You can add an image to this registry with the following commands:',
)
}}
</p>
<div class="input-group append-bottom-10">
<input :value="dockerBuildCommand" type="text" class="form-control monospace" readonly />
<span class="input-group-append">
<clipboard-button
:text="dockerBuildCommand"
:title="s__('ContainerRegistry|Copy build command')"
class="input-group-text"
/>
</span>
</div>
<div class="input-group">
<input :value="dockerPushCommand" type="text" class="form-control monospace" readonly />
<span class="input-group-append">
<clipboard-button
:text="dockerPushCommand"
:title="s__('ContainerRegistry|Copy push command')"
class="input-group-text"
/>
</span>
</div>
</template>
</gl-empty-state>
<project-empty-state
v-else-if="!isGroupPage"
:no-containers-image="noContainersImage"
:help-page-path="helpPagePath"
:repository-url="repositoryUrl"
:two-factor-auth-help-link="twoFactorAuthHelpLink"
:personal-access-tokens-help-link="personalAccessTokensHelpLink"
:registry-host-url-with-port="registryHostUrlWithPort"
/>
<group-empty-state
v-else-if="isGroupPage"
:no-containers-image="noContainersImage"
:help-page-path="helpPagePath"
/>
</div>
</template>
<script>
import { mapActions } from 'vuex';
import { mapActions, mapGetters } from 'vuex';
import { GlLoadingIcon, GlButton, GlTooltipDirective, GlModal, GlModalDirective } from '@gitlab/ui';
import createFlash from '../../flash';
import ClipboardButton from '../../vue_shared/components/clipboard_button.vue';
......@@ -35,9 +35,13 @@ export default {
};
},
computed: {
...mapGetters(['isDeleteDisabled']),
iconName() {
return this.isOpen ? 'angle-up' : 'angle-right';
},
canDeleteRepo() {
return this.repo.canDelete && !this.isDeleteDisabled;
},
},
methods: {
...mapActions(['fetchRepos', 'fetchList', 'deleteItem']),
......@@ -80,7 +84,7 @@ export default {
<div class="controls d-none d-sm-block float-right">
<gl-button
v-if="repo.canDelete"
v-if="canDeleteRepo"
v-gl-tooltip
v-gl-modal="modalId"
:title="s__('ContainerRegistry|Remove repository')"
......@@ -98,7 +102,7 @@ export default {
<gl-loading-icon v-if="repo.isLoading" size="md" class="append-bottom-20" />
<div v-else-if="!repo.isLoading && isOpen" class="container-image-tags">
<table-registry v-if="repo.list.length" :repo="repo" />
<table-registry v-if="repo.list.length" :repo="repo" :can-delete-repo="canDeleteRepo" />
<div v-else class="nothing-here-block">
{{ s__('ContainerRegistry|No tags in Container Registry for this container image.') }}
......
<script>
import { GlEmptyState } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
export default {
name: 'GroupEmptyState',
components: {
GlEmptyState,
},
props: {
noContainersImage: {
type: String,
required: true,
},
helpPagePath: {
type: String,
required: true,
},
},
computed: {
noContainerImagesText() {
return sprintf(
s__(
`ContainerRegistry|With the Container Registry, every project can have its own space to store its Docker images. Push at least one Docker image in one of this group's projects in order to show up here. %{docLinkStart}More Information%{docLinkEnd}`,
),
{
docLinkStart: `<a href="${this.helpPagePath}" target="_blank">`,
docLinkEnd: '</a>',
},
false,
);
},
},
};
</script>
<template>
<gl-empty-state
:title="s__('ContainerRegistry|There are no container images available in this group')"
:svg-path="noContainersImage"
class="container-message"
>
<template #description>
<p class="js-no-container-images-text" v-html="noContainerImagesText"></p>
</template>
</gl-empty-state>
</template>
<script>
import { GlEmptyState } from '@gitlab/ui';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import { s__, sprintf } from '~/locale';
export default {
name: 'ProjectEmptyState',
components: {
ClipboardButton,
GlEmptyState,
},
props: {
noContainersImage: {
type: String,
required: true,
},
repositoryUrl: {
type: String,
required: true,
},
helpPagePath: {
type: String,
required: true,
},
twoFactorAuthHelpLink: {
type: String,
required: true,
},
personalAccessTokensHelpLink: {
type: String,
required: true,
},
registryHostUrlWithPort: {
type: String,
required: true,
},
},
computed: {
dockerBuildCommand() {
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
return `docker build -t ${this.repositoryUrl} .`;
},
dockerPushCommand() {
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
return `docker push ${this.repositoryUrl}`;
},
dockerLoginCommand() {
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
return `docker login ${this.registryHostUrlWithPort}`;
},
noContainerImagesText() {
return sprintf(
s__(`ContainerRegistry|With the Container Registry, every project can have its own space to
store its Docker images. %{docLinkStart}More Information%{docLinkEnd}`),
{
docLinkStart: `<a href="${this.helpPagePath}" target="_blank">`,
docLinkEnd: '</a>',
},
false,
);
},
notLoggedInToRegistryText() {
return sprintf(
s__(`ContainerRegistry|If you are not already logged in, you need to authenticate to
the Container Registry by using your GitLab username and password. If you have
%{twofaDocLinkStart}Two-Factor Authentication%{twofaDocLinkEnd} enabled, use a
%{personalAccessTokensDocLinkStart}Personal Access Token%{personalAccessTokensDocLinkEnd}
instead of a password.`),
{
twofaDocLinkStart: `<a href="${this.twoFactorAuthHelpLink}" target="_blank">`,
twofaDocLinkEnd: '</a>',
personalAccessTokensDocLinkStart: `<a href="${this.personalAccessTokensHelpLink}" target="_blank">`,
personalAccessTokensDocLinkEnd: '</a>',
},
false,
);
},
},
};
</script>
<template>
<gl-empty-state
:title="s__('ContainerRegistry|There are no container images stored for this project')"
:svg-path="noContainersImage"
class="container-message"
>
<template #description>
<p class="js-no-container-images-text" v-html="noContainerImagesText"></p>
<h5>{{ s__('ContainerRegistry|Quick Start') }}</h5>
<p class="js-not-logged-in-to-registry-text" v-html="notLoggedInToRegistryText"></p>
<div class="input-group append-bottom-10">
<input :value="dockerLoginCommand" type="text" class="form-control monospace" readonly />
<span class="input-group-append">
<clipboard-button
:text="dockerLoginCommand"
:title="s__('ContainerRegistry|Copy login command')"
class="input-group-text"
/>
</span>
</div>
<p></p>
<p>
{{
s__(
'ContainerRegistry|You can add an image to this registry with the following commands:',
)
}}
</p>
<div class="input-group append-bottom-10">
<input :value="dockerBuildCommand" type="text" class="form-control monospace" readonly />
<span class="input-group-append">
<clipboard-button
:text="dockerBuildCommand"
:title="s__('ContainerRegistry|Copy build command')"
class="input-group-text"
/>
</span>
</div>
<div class="input-group">
<input :value="dockerPushCommand" type="text" class="form-control monospace" readonly />
<span class="input-group-append">
<clipboard-button
:text="dockerPushCommand"
:title="s__('ContainerRegistry|Copy push command')"
class="input-group-text"
/>
</span>
</div>
</template>
</gl-empty-state>
</template>
<script>
import { mapActions } from 'vuex';
import { mapActions, mapGetters } from 'vuex';
import {
GlButton,
GlFormCheckbox,
......@@ -35,6 +35,11 @@ export default {
type: Object,
required: true,
},
canDeleteRepo: {
type: Boolean,
default: false,
required: false,
},
},
data() {
return {
......@@ -45,6 +50,7 @@ export default {
};
},
computed: {
...mapGetters(['isDeleteDisabled']),
bulkDeletePath() {
return this.repo.tagsPath ? this.repo.tagsPath.replace('?format=json', '/bulk_destroy') : '';
},
......@@ -165,6 +171,9 @@ export default {
}
}
},
canDeleteRow(item) {
return item && item.canDelete && !this.isDeleteDisabled;
},
},
};
</script>
......@@ -175,7 +184,7 @@ export default {
<tr>
<th>
<gl-form-checkbox
v-if="repo.canDelete"
v-if="canDeleteRepo"
class="js-select-all-checkbox"
:checked="selectAllChecked"
@change="onSelectAllChange"
......@@ -187,7 +196,7 @@ export default {
<th>{{ s__('ContainerRegistry|Last Updated') }}</th>
<th>
<gl-button
v-if="repo.canDelete"
v-if="canDeleteRepo"
v-gl-tooltip
v-gl-modal="modalId"
:disabled="!itemsToBeDeleted || itemsToBeDeleted.length === 0"
......@@ -208,7 +217,7 @@ export default {
<tr v-for="(item, index) in repo.list" :key="item.tag" class="registry-image-row">
<td class="check">
<gl-form-checkbox
v-if="item.canDelete"
v-if="canDeleteRow(item)"
class="js-select-checkbox"
:checked="itemsToBeDeleted && itemsToBeDeleted.includes(index)"
@change="updateItemsToBeDeleted(index)"
......@@ -244,7 +253,7 @@ export default {
<td class="content action-buttons">
<gl-button
v-if="item.canDelete"
v-if="canDeleteRow(item)"
v-gl-modal="modalId"
:title="s__('ContainerRegistry|Remove tag')"
:aria-label="s__('ContainerRegistry|Remove tag')"
......
......@@ -13,29 +13,24 @@ export default () =>
data() {
const { dataset } = document.querySelector(this.$options.el);
return {
characterError: Boolean(dataset.characterError),
containersErrorImage: dataset.containersErrorImage,
endpoint: dataset.endpoint,
helpPagePath: dataset.helpPagePath,
noContainersImage: dataset.noContainersImage,
personalAccessTokensHelpLink: dataset.personalAccessTokensHelpLink,
registryHostUrlWithPort: dataset.registryHostUrlWithPort,
repositoryUrl: dataset.repositoryUrl,
twoFactorAuthHelpLink: dataset.twoFactorAuthHelpLink,
registryData: {
endpoint: dataset.endpoint,
characterError: Boolean(dataset.characterError),
helpPagePath: dataset.helpPagePath,
noContainersImage: dataset.noContainersImage,
containersErrorImage: dataset.containersErrorImage,
repositoryUrl: dataset.repositoryUrl,
isGroupPage: dataset.isGroupPage,
personalAccessTokensHelpLink: dataset.personalAccessTokensHelpLink,
registryHostUrlWithPort: dataset.registryHostUrlWithPort,
twoFactorAuthHelpLink: dataset.twoFactorAuthHelpLink,
},
};
},
render(createElement) {
return createElement('registry-app', {
props: {
characterError: this.characterError,
containersErrorImage: this.containersErrorImage,
endpoint: this.endpoint,
helpPagePath: this.helpPagePath,
noContainersImage: this.noContainersImage,
personalAccessTokensHelpLink: this.personalAccessTokensHelpLink,
registryHostUrlWithPort: this.registryHostUrlWithPort,
repositoryUrl: this.repositoryUrl,
twoFactorAuthHelpLink: this.twoFactorAuthHelpLink,
...this.registryData,
},
});
},
......
......@@ -20,7 +20,6 @@ export const fetchRepos = ({ commit, state }) => {
export const fetchList = ({ commit }, { repo, page }) => {
commit(types.TOGGLE_REGISTRY_LIST_LOADING, repo);
return axios
.get(repo.tagsPath, { params: { page } })
.then(response => {
......@@ -40,6 +39,7 @@ export const multiDeleteItems = (_, { path, items }) =>
axios.delete(path, { params: { ids: items } });
export const setMainEndpoint = ({ commit }, data) => commit(types.SET_MAIN_ENDPOINT, data);
export const setIsDeleteDisabled = ({ commit }, data) => commit(types.SET_IS_DELETE_DISABLED, data);
export const toggleLoading = ({ commit }) => commit(types.TOGGLE_MAIN_LOADING);
// prevent babel-plugin-rewire from generating an invalid default during karma tests
......
export const isLoading = state => state.isLoading;
export const repos = state => state.repos;
export const isDeleteDisabled = state => state.isDeleteDisabled;
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
export const SET_MAIN_ENDPOINT = 'SET_MAIN_ENDPOINT';
export const SET_IS_DELETE_DISABLED = 'SET_IS_DELETE_DISABLED';
export const SET_REPOS_LIST = 'SET_REPOS_LIST';
export const TOGGLE_MAIN_LOADING = 'TOGGLE_MAIN_LOADING';
......
......@@ -6,6 +6,10 @@ export default {
Object.assign(state, { endpoint });
},
[types.SET_IS_DELETE_DISABLED](state, isDeleteDisabled) {
Object.assign(state, { isDeleteDisabled });
},
[types.SET_REPOS_LIST](state, list) {
Object.assign(state, {
repos: list.map(el => ({
......@@ -17,6 +21,7 @@ export default {
location: el.location,
name: el.path,
tagsPath: el.tags_path,
projectId: el.project_id,
})),
});
},
......
export default () => ({
isLoading: false,
endpoint: '', // initial endpoint to fetch the repos list
isDeleteDisabled: false, // controls the delete buttons in the registry
/**
* Each object in `repos` has the following strucure:
* {
......
# frozen_string_literal: true
module Groups
module Registry
class RepositoriesController < Groups::ApplicationController
before_action :verify_container_registry_enabled!
before_action :authorize_read_container_image!
def index
track_event(:list_repositories)
respond_to do |format|
format.html
format.json do
@images = group.container_repositories.with_api_entity_associations
render json: ContainerRepositoriesSerializer
.new(current_user: current_user)
.represent(@images)
end
end
end
private
def verify_container_registry_enabled!
render_404 unless Gitlab.config.registry.enabled
end
def authorize_read_container_image!
return render_404 unless can?(current_user, :read_container_image, group)
end
end
end
end
......@@ -15,6 +15,16 @@ module GroupsHelper
%w[groups#projects groups#edit badges#index ci_cd#show ldap_group_links#index hooks#index audit_events#index pipeline_quota#index]
end
def group_packages_nav_link_paths
%w[
groups/container_registries#index
]
end
def group_container_registry_nav?
Gitlab.config.registry.enabled && can?(current_user, :read_container_image, @group)
end
def group_sidebar_links
@group_sidebar_links ||= get_group_sidebar_links
end
......
......@@ -11,6 +11,7 @@ class ContainerRepository < ApplicationRecord
delegate :client, to: :registry
scope :ordered, -> { order(:name) }
scope :with_api_entity_associations, -> { preload(:project) }
# rubocop: disable CodeReuse/ServiceClass
def registry
......
......@@ -18,7 +18,7 @@ class ContainerRepositoryEntity < Grape::Entity
alias_method :repository, :object
def project
request.project
request.respond_to?(:project) ? request.project : object.project
end
def can_destroy?
......
- page_title _("Container Registry")
%section
.row.registry-placeholder.prepend-bottom-10
.col-12
#js-vue-registry-images{ data: { endpoint: group_container_registries_path(@group, format: :json),
"help_page_path" => help_page_path('user/packages/container_registry/index'),
"no_containers_image" => image_path('illustrations/docker-empty-state.svg'),
"containers_error_image" => image_path('illustrations/docker-error-state.svg'),
"repository_url" => "",
is_group_page: true,
character_error: @character_error.to_s } }
- if group_container_registry_nav?
= nav_link(path: group_packages_nav_link_paths) do
= link_to group_container_registries_path(@group), title: _('Container Registry') do
.nav-icon-container
= sprite_icon('package')
%span.nav-item-name
= _('Packages')
%ul.sidebar-sub-level-items
= nav_link(controller: [:packages, :repositories], html_options: { class: "fly-out-top-item" } ) do
= link_to group_container_registries_path(@group), title: _('Container Registry') do
%strong.fly-out-top-item-name
= _('Packages')
%li.divider.fly-out-top-item
= nav_link(controller: 'groups/container_registries') do
= link_to group_container_registries_path(@group), title: _('Container Registry') do
%span= _('Container Registry')
......@@ -118,7 +118,7 @@
%strong.fly-out-top-item-name
= _('Kubernetes')
= render_if_exists 'groups/sidebar/packages' # EE-specific
= render_if_exists 'groups/sidebar/packages'
- if group_sidebar_link?(:group_members)
= nav_link(path: 'group_members#index') do
......
---
title: Group level Container Registry browser
merge_request: 17615
author:
type: added
......@@ -77,6 +77,8 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
post :pause
end
end
resources :container_registries, only: [:index], controller: 'registry/repositories'
end
scope(path: '*id',
......
......@@ -33,16 +33,19 @@ module EE
"Repositories within this group #{show_lfs} will be restricted to this maximum size. Can be overridden inside each project. 0 for unlimited. Leave empty to inherit the global value."
end
override :group_packages_nav_link_paths
def group_packages_nav_link_paths
%w[
groups/packages#index
groups/dependency_proxies#show
groups/container_registries#index
]
end
def group_packages_nav?
group_packages_list_nav? ||
group_dependency_proxy_nav?
group_dependency_proxy_nav? ||
group_container_registry_nav?
end
def group_packages_list_nav?
......
- packages_link = group_packages_list_nav? ? group_packages_path(@group) : group_container_registries_path(@group)
- if group_packages_nav?
= nav_link(path: group_packages_nav_link_paths) do
= link_to group_packages_path(@group) do
= link_to packages_link, title: _('Packages') do
.nav-icon-container
= sprite_icon('package')
%span.nav-item-name
= _('Packages')
%ul.sidebar-sub-level-items
= nav_link(controller: [:packages, :repositories], html_options: { class: "fly-out-top-item" } ) do
= link_to group_packages_path(@group) do
= link_to packages_link, title: _('Packages') do
%strong.fly-out-top-item-name
= _('Packages')
%li.divider.fly-out-top-item
......@@ -15,6 +17,10 @@
= nav_link(controller: 'groups/packages') do
= link_to group_packages_path(@group), title: _('Packages') do
%span= _('List')
- if group_container_registry_nav?
= nav_link(controller: 'groups/container_registries') do
= link_to group_container_registries_path(@group), title: _('Container Registry') do
%span= _('Container Registry')
- if group_dependency_proxy_nav?
= nav_link(controller: 'groups/dependency_proxies') do
= link_to group_dependency_proxy_path(@group), title: _('Dependency Proxy') do
......
......@@ -4352,7 +4352,7 @@ msgstr ""
msgid "ContainerRegistry|Docker connection error"
msgstr ""
msgid "ContainerRegistry|If you are not already logged in, you need to authenticate to the Container Registry by using your GitLab username and password. If you have %{twofaDocLinkStart}Two-Factor Authentication%{twofaDocLinkEnd} enabled, use a %{personalAccessTokensDocLinkStart}Personal Access Token %{personalAccessTokensDocLinkEnd}instead of a password."
msgid "ContainerRegistry|If you are not already logged in, you need to authenticate to the Container Registry by using your GitLab username and password. If you have %{twofaDocLinkStart}Two-Factor Authentication%{twofaDocLinkEnd} enabled, use a %{personalAccessTokensDocLinkStart}Personal Access Token%{personalAccessTokensDocLinkEnd} instead of a password."
msgstr ""
msgid "ContainerRegistry|Last Updated"
......@@ -4384,6 +4384,9 @@ msgstr ""
msgid "ContainerRegistry|Tag ID"
msgstr ""
msgid "ContainerRegistry|There are no container images available in this group"
msgstr ""
msgid "ContainerRegistry|There are no container images stored for this project"
msgstr ""
......@@ -4393,6 +4396,9 @@ msgstr ""
msgid "ContainerRegistry|With the Container Registry, every project can have its own space to store its Docker images. %{docLinkStart}More Information%{docLinkEnd}"
msgstr ""
msgid "ContainerRegistry|With the Container Registry, every project can have its own space to store its Docker images. Push at least one Docker image in one of this group's projects in order to show up here. %{docLinkStart}More Information%{docLinkEnd}"
msgstr ""
msgid "ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images. %{docLinkStart}More Information%{docLinkEnd}"
msgstr ""
......
# frozen_string_literal: true
require 'spec_helper'
describe Groups::Registry::RepositoriesController do
let_it_be(:user) { create(:user) }
let_it_be(:guest) { create(:user) }
let_it_be(:group, reload: true) { create(:group) }
before do
stub_container_registry_config(enabled: true)
group.add_owner(user)
group.add_guest(guest)
sign_in(user)
end
context 'GET #index' do
context 'when container registry is enabled' do
it 'show index page' do
get :index, params: {
group_id: group
}
expect(response).to have_gitlab_http_status(:ok)
end
it 'has the correct response schema' do
get :index, params: {
group_id: group,
format: :json
}
expect(response).to match_response_schema('registry/repositories')
end
it 'returns a list of projects for json format' do
project = create(:project, group: group)
repo = create(:container_repository, project: project)
get :index, params: {
group_id: group,
format: :json
}
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_kind_of(Array)
expect(json_response.first).to include(
'id' => repo.id,
'name' => repo.name
)
end
it 'tracks the event' do
expect(Gitlab::Tracking).to receive(:event).with(anything, 'list_repositories', {})
get :index, params: {
group_id: group
}
end
end
context 'container registry is disabled' do
before do
stub_container_registry_config(enabled: false)
end
it 'renders not found' do
get :index, params: {
group_id: group
}
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'user do not have acces to container registry' do
before do
sign_out(user)
sign_in(guest)
end
it 'renders not found' do
get :index, params: {
group_id: group
}
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe 'Groups > sidebar' do
let(:user) { create(:user) }
let(:group) { create(:group) }
before do
group.add_developer(user)
sign_in(user)
end
context 'Package menu' do
context 'when container registry is enabled' do
before do
stub_container_registry_config(enabled: true)
visit group_path(group)
end
it 'shows main menu' do
within '.nav-sidebar' do
expect(page).to have_link(_('Packages'))
end
end
it 'has container registry link' do
within '.nav-sidebar' do
expect(page).to have_link(_('Container Registry'))
end
end
end
context 'when container registry is disabled' do
before do
stub_container_registry_config(enabled: false)
visit group_path(group)
end
it 'does not have container registry link' do
within '.nav-sidebar' do
expect(page).not_to have_link(_('Container Registry'))
end
end
end
end
end
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Registry Group Empty state to match the default snapshot 1`] = `
<div
class="row container-message empty-state"
>
<div
class="col-12"
>
<div
class="svg-250 svg-content"
>
<img
alt="There are no container images available in this group"
class=""
src="imageUrl"
/>
</div>
</div>
<div
class="col-12"
>
<div
class="text-content"
>
<h4
class="center"
style=""
>
There are no container images available in this group
</h4>
<p
class="center"
style=""
>
<p
class="js-no-container-images-text"
>
With the Container Registry, every project can have its own space to store its Docker images. Push at least one Docker image in one of this group's projects in order to show up here.
<a
href="help"
target="_blank"
>
More Information
</a>
</p>
</p>
<div
class="text-center"
>
<!---->
<!---->
</div>
</div>
</div>
</div>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Registry Project Empty state to match the default snapshot 1`] = `
<div
class="row container-message empty-state"
>
<div
class="col-12"
>
<div
class="svg-250 svg-content"
>
<img
alt="There are no container images stored for this project"
class=""
src="imageUrl"
/>
</div>
</div>
<div
class="col-12"
>
<div
class="text-content"
>
<h4
class="center"
style=""
>
There are no container images stored for this project
</h4>
<p
class="center"
style=""
>
<p
class="js-no-container-images-text"
>
With the Container Registry, every project can have its own space to store its Docker images.
<a
href="help"
target="_blank"
>
More Information
</a>
</p>
<h5>
Quick Start
</h5>
<p
class="js-not-logged-in-to-registry-text"
>
If you are not already logged in, you need to authenticate to the Container Registry by using your GitLab username and password. If you have
<a
href="help_link"
target="_blank"
>
Two-Factor Authentication
</a>
enabled, use a
<a
href="personal_token"
target="_blank"
>
Personal Access Token
</a>
instead of a password.
</p>
<div
class="input-group append-bottom-10"
>
<input
class="form-control monospace"
readonly="readonly"
type="text"
/>
<span
class="input-group-append"
>
<button
class="btn input-group-text btn-secondary btn-default"
data-clipboard-text="docker login host"
data-original-title="Copy login command"
title=""
type="button"
>
<svg
aria-hidden="true"
class="s16 ic-duplicate"
>
<use
xlink:href="#duplicate"
/>
</svg>
</button>
</span>
</div>
<p />
<p>
You can add an image to this registry with the following commands:
</p>
<div
class="input-group append-bottom-10"
>
<input
class="form-control monospace"
readonly="readonly"
type="text"
/>
<span
class="input-group-append"
>
<button
class="btn input-group-text btn-secondary btn-default"
data-clipboard-text="docker build -t url ."
data-original-title="Copy build command"
title=""
type="button"
>
<svg
aria-hidden="true"
class="s16 ic-duplicate"
>
<use
xlink:href="#duplicate"
/>
</svg>
</button>
</span>
</div>
<div
class="input-group"
>
<input
class="form-control monospace"
readonly="readonly"
type="text"
/>
<span
class="input-group-append"
>
<button
class="btn input-group-text btn-secondary btn-default"
data-clipboard-text="docker push url"
data-original-title="Copy push command"
title=""
type="button"
>
<svg
aria-hidden="true"
class="s16 ic-duplicate"
>
<use
xlink:href="#duplicate"
/>
</svg>
</button>
</span>
</div>
</p>
<div
class="text-center"
>
<!---->
<!---->
</div>
</div>
</div>
</div>
`;
import Vue from 'vue';
import { mount } from '@vue/test-utils';
import registry from '~/registry/components/app.vue';
import { TEST_HOST } from '../../helpers/test_constants';
......@@ -7,8 +8,8 @@ describe('Registry List', () => {
let wrapper;
const findCollapsibleContainer = w => w.findAll({ name: 'CollapsibeContainerRegisty' });
const findNoContainerImagesText = w => w.find('.js-no-container-images-text');
const findNotLoggedInToRegistryText = w => w.find('.js-not-logged-in-to-registry-text');
const findProjectEmptyState = w => w.find({ name: 'ProjectEmptyState' });
const findGroupEmptyState = w => w.find({ name: 'GroupEmptyState' });
const findSpinner = w => w.find('.gl-spinner');
const findCharacterErrorText = w => w.find('.js-character-error-text');
......@@ -25,13 +26,18 @@ describe('Registry List', () => {
const setMainEndpoint = jest.fn();
const fetchRepos = jest.fn();
const setIsDeleteDisabled = jest.fn();
const methods = {
setMainEndpoint,
fetchRepos,
setIsDeleteDisabled,
};
beforeEach(() => {
// This is needed due to console.error called by vue to emit a warning that stop the tests.
// See https://github.com/vuejs/vue-test-utils/issues/532.
Vue.config.silent = true;
wrapper = mount(registry, {
propsData,
computed: {
......@@ -43,6 +49,12 @@ describe('Registry List', () => {
});
});
afterEach(() => {
jest.clearAllMocks();
Vue.config.silent = false;
wrapper.destroy();
});
describe('with data', () => {
it('should render a list of CollapsibeContainerRegisty', () => {
const containers = findCollapsibleContainer(wrapper);
......@@ -65,18 +77,9 @@ describe('Registry List', () => {
});
});
it('should render empty message', () => {
const noContainerImagesText = findNoContainerImagesText(localWrapper);
expect(noContainerImagesText.text()).toEqual(
'With the Container Registry, every project can have its own space to store its Docker images. More Information',
);
});
it('should render login help text', () => {
const notLoggedInToRegistryText = findNotLoggedInToRegistryText(localWrapper);
expect(notLoggedInToRegistryText.text()).toEqual(
'If you are not already logged in, you need to authenticate to the Container Registry by using your GitLab username and password. If you have Two-Factor Authentication enabled, use a Personal Access Token instead of a password.',
);
it('should render project empty message', () => {
const projectEmptyState = findProjectEmptyState(localWrapper);
expect(projectEmptyState.exists()).toBe(true);
});
});
......@@ -129,4 +132,29 @@ describe('Registry List', () => {
);
});
});
describe('with groupId set', () => {
const isGroupPage = true;
beforeEach(() => {
wrapper = mount(registry, {
propsData: {
...propsData,
endpoint: null,
isGroupPage,
},
methods,
});
});
it('call the right vuex setters', () => {
expect(methods.setMainEndpoint).toHaveBeenLastCalledWith(null);
expect(methods.setIsDeleteDisabled).toHaveBeenLastCalledWith(true);
});
it('should render groups empty message', () => {
const groupEmptyState = findGroupEmptyState(wrapper);
expect(groupEmptyState.exists()).toBe(true);
});
});
});
import Vue from 'vue';
import { mount } from '@vue/test-utils';
import Vuex from 'vuex';
import { mount, createLocalVue } from '@vue/test-utils';
import collapsibleComponent from '~/registry/components/collapsible_container.vue';
import { repoPropsData } from '../mock_data';
import createFlash from '~/flash';
import * as getters from '~/registry/stores/getters';
jest.mock('~/flash.js');
const localVue = createLocalVue();
localVue.use(Vuex);
describe('collapsible registry container', () => {
let wrapper;
let store;
const findDeleteBtn = w => w.find('.js-remove-repo');
const findContainerImageTags = w => w.find('.container-image-tags');
const findToggleRepos = w => w.findAll('.js-toggle-repo');
const mountWithStore = config => mount(collapsibleComponent, { ...config, store, localVue });
beforeEach(() => {
createFlash.mockClear();
// This is needed due to console.error called by vue to emit a warning that stop the tests
// see https://github.com/vuejs/vue-test-utils/issues/532
Vue.config.silent = true;
wrapper = mount(collapsibleComponent, {
store = new Vuex.Store({
state: {
isDeleteDisabled: false,
},
getters,
});
wrapper = mountWithStore({
propsData: {
repo: repoPropsData,
},
......@@ -27,6 +43,7 @@ describe('collapsible registry container', () => {
afterEach(() => {
Vue.config.silent = false;
wrapper.destroy();
});
describe('toggle', () => {
......@@ -86,4 +103,25 @@ describe('collapsible registry container', () => {
});
});
});
describe('disabled delete', () => {
beforeEach(() => {
store = new Vuex.Store({
state: {
isDeleteDisabled: true,
},
getters,
});
wrapper = mountWithStore({
propsData: {
repo: repoPropsData,
},
});
});
it('should not render delete button', () => {
const deleteBtn = findDeleteBtn(wrapper);
expect(deleteBtn.exists()).toBe(false);
});
});
});
import { mount } from '@vue/test-utils';
import groupEmptyState from '~/registry/components/group_empty_state.vue';
describe('Registry Group Empty state', () => {
let wrapper;
beforeEach(() => {
wrapper = mount(groupEmptyState, {
propsData: {
noContainersImage: 'imageUrl',
helpPagePath: 'help',
},
});
});
afterEach(() => {
wrapper.destroy();
});
it('to match the default snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
});
import { mount } from '@vue/test-utils';
import projectEmptyState from '~/registry/components/project_empty_state.vue';
describe('Registry Project Empty state', () => {
let wrapper;
beforeEach(() => {
wrapper = mount(projectEmptyState, {
propsData: {
noContainersImage: 'imageUrl',
helpPagePath: 'help',
repositoryUrl: 'url',
twoFactorAuthHelpLink: 'help_link',
personalAccessTokensHelpLink: 'personal_token',
registryHostUrlWithPort: 'host',
},
});
});
afterEach(() => {
wrapper.destroy();
});
it('to match the default snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
});
import Vue from 'vue';
import Vuex from 'vuex';
import tableRegistry from '~/registry/components/table_registry.vue';
import { mount } from '@vue/test-utils';
import { mount, createLocalVue } from '@vue/test-utils';
import { repoPropsData } from '../mock_data';
import * as getters from '~/registry/stores/getters';
const [firstImage, secondImage] = repoPropsData.list;
const localVue = createLocalVue();
localVue.use(Vuex);
describe('table registry', () => {
let wrapper;
let store;
const findSelectAllCheckbox = w => w.find('.js-select-all-checkbox > input');
const findSelectCheckboxes = w => w.findAll('.js-select-checkbox > input');
......@@ -15,19 +22,31 @@ describe('table registry', () => {
const findPagination = w => w.find('.js-registry-pagination');
const bulkDeletePath = 'path';
const mountWithStore = config => mount(tableRegistry, { ...config, store, localVue });
beforeEach(() => {
// This is needed due to console.error called by vue to emit a warning that stop the tests
// see https://github.com/vuejs/vue-test-utils/issues/532
Vue.config.silent = true;
wrapper = mount(tableRegistry, {
store = new Vuex.Store({
state: {
isDeleteDisabled: false,
},
getters,
});
wrapper = mountWithStore({
propsData: {
repo: repoPropsData,
canDeleteRepo: true,
},
});
});
afterEach(() => {
Vue.config.silent = false;
wrapper.destroy();
});
describe('rendering', () => {
......@@ -149,7 +168,6 @@ describe('table registry', () => {
});
describe('pagination', () => {
let localWrapper = null;
const repo = {
repoPropsData,
pagination: {
......@@ -160,7 +178,7 @@ describe('table registry', () => {
};
beforeEach(() => {
localWrapper = mount(tableRegistry, {
wrapper = mount(tableRegistry, {
propsData: {
repo,
},
......@@ -168,13 +186,13 @@ describe('table registry', () => {
});
it('should exist', () => {
const pagination = findPagination(localWrapper);
const pagination = findPagination(wrapper);
expect(pagination.exists()).toBe(true);
});
it('should be visible when pagination is needed', () => {
const pagination = findPagination(localWrapper);
const pagination = findPagination(wrapper);
expect(pagination.isVisible()).toBe(true);
localWrapper.setProps({
wrapper.setProps({
repo: {
pagination: {
total: 0,
......@@ -182,13 +200,13 @@ describe('table registry', () => {
},
},
});
expect(localWrapper.vm.shouldRenderPagination).toBe(false);
expect(wrapper.vm.shouldRenderPagination).toBe(false);
});
it('should have a change function that update the list when run', () => {
const fetchList = jest.fn().mockResolvedValue();
localWrapper.setMethods({ fetchList });
localWrapper.vm.onPageChange(1);
expect(localWrapper.vm.fetchList).toHaveBeenCalledWith({ repo, page: 1 });
wrapper.setMethods({ fetchList });
wrapper.vm.onPageChange(1);
expect(wrapper.vm.fetchList).toHaveBeenCalledWith({ repo, page: 1 });
});
});
......@@ -208,4 +226,41 @@ describe('table registry', () => {
expect(wrapper.vm.modalDescription).toContain('<b>2</b> tags');
});
});
describe('disabled delete', () => {
beforeEach(() => {
store = new Vuex.Store({
state: {
isDeleteDisabled: true,
},
getters,
});
wrapper = mountWithStore({
propsData: {
repo: repoPropsData,
canDeleteRepo: false,
},
});
});
it('should not render select all', () => {
const selectAll = findSelectAllCheckbox(wrapper);
expect(selectAll.exists()).toBe(false);
});
it('should not render any select checkbox', () => {
const selects = findSelectCheckboxes(wrapper);
expect(selects.length).toBe(0);
});
it('should not render delete registry button', () => {
const deleteBtn = findDeleteButton(wrapper);
expect(deleteBtn.exists()).toBe(false);
});
it('should not render delete row button', () => {
const deleteBtns = findDeleteButtonsRow(wrapper);
expect(deleteBtns.length).toBe(0);
});
});
});
......@@ -34,7 +34,7 @@ describe('Actions Registry Store', () => {
mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, reposServerResponse, {});
});
it('should set receveived repos', done => {
it('should set received repos', done => {
testAction(
actions.fetchRepos,
null,
......@@ -71,10 +71,10 @@ describe('Actions Registry Store', () => {
beforeEach(() => {
state.repos = parsedReposServerResponse;
[, repo] = state.repos;
mock.onGet(repo.tagsPath).replyOnce(200, registryServerResponse, {});
});
it('should set received list', done => {
mock.onGet(repo.tagsPath).replyOnce(200, registryServerResponse, {});
testAction(
actions.fetchList,
{ repo },
......@@ -97,6 +97,7 @@ describe('Actions Registry Store', () => {
});
it('should create flash on API error', done => {
mock.onGet(repo.tagsPath).replyOnce(400);
const updatedRepo = {
...repo,
tagsPath: null,
......@@ -133,6 +134,19 @@ describe('Actions Registry Store', () => {
});
});
describe('setIsDeleteDisabled', () => {
it('should commit set is delete disabled', done => {
testAction(
actions.setIsDeleteDisabled,
true,
state,
[{ type: types.SET_IS_DELETE_DISABLED, payload: true }],
[],
done,
);
});
});
describe('toggleLoading', () => {
it('should commit toggle main loading', done => {
testAction(
......
......@@ -7,6 +7,7 @@ describe('Getters Registry Store', () => {
state = {
isLoading: false,
endpoint: '/root/empty-project/container_registry.json',
isDeleteDisabled: false,
repos: [
{
canDelete: true,
......@@ -43,4 +44,9 @@ describe('Getters Registry Store', () => {
expect(getters.repos(state)).toEqual(state.repos);
});
});
describe('isDeleteDisabled', () => {
it('should return isDeleteDisabled', () => {
expect(getters.isDeleteDisabled(state)).toEqual(state.isDeleteDisabled);
});
});
});
......@@ -19,7 +19,16 @@ describe('Mutations Registry Store', () => {
const expectedState = Object.assign({}, mockState, { endpoint: 'foo' });
mutations[types.SET_MAIN_ENDPOINT](mockState, 'foo');
expect(mockState).toEqual(expectedState);
expect(mockState.endpoint).toEqual(expectedState.endpoint);
});
});
describe('SET_IS_DELETE_DISABLED', () => {
it('should set the is delete disabled', () => {
const expectedState = Object.assign({}, mockState, { isDeleteDisabled: true });
mutations[types.SET_IS_DELETE_DISABLED](mockState, true);
expect(mockState.isDeleteDisabled).toEqual(expectedState.isDeleteDisabled);
});
});
......
......@@ -191,6 +191,41 @@ describe GroupsHelper do
end
end
describe '#group_container_registry_nav' do
let(:group) { create(:group, :public) }
let(:user) { create(:user) }
before do
stub_container_registry_config(enabled: true)
allow(helper).to receive(:current_user) { user }
allow(helper).to receive(:can?).with(user, :read_container_image, group) { true }
helper.instance_variable_set(:@group, group)
end
subject { helper.group_container_registry_nav? }
context 'when container registry is enabled' do
it { is_expected.to be_truthy }
it 'is disabled for guest' do
allow(helper).to receive(:can?).with(user, :read_container_image, group) { false }
expect(subject).to be false
end
end
context 'when container registry is not enabled' do
before do
stub_container_registry_config(enabled: false)
end
it { is_expected.to be_falsy }
it 'is disabled for guests' do
allow(helper).to receive(:can?).with(user, :read_container_image, group) { false }
expect(subject).to be false
end
end
end
describe '#group_sidebar_links' do
let(:group) { create(:group, :public) }
let(:user) { create(:user) }
......
......@@ -25,6 +25,18 @@ describe ContainerRepositoryEntity do
expect(subject).to include(:id, :path, :location, :tags_path)
end
context 'when project is not preset in the request' do
before do
allow(request).to receive(:respond_to?).and_return(false)
allow(request).to receive(:project).and_return(nil)
end
it 'uses project from the object' do
expect(request.project).not_to equal(project)
expect(subject).to include(:tags_path)
end
end
context 'when user can manage repositories' do
before do
project.add_developer(user)
......
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